From 35c8558c99e4d93173b9e235f6c8911b91554928 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Mon, 8 Apr 2024 22:08:01 +0200 Subject: [PATCH 01/11] cleanup and fix bugs --- .ruff.toml | 2 - sphinx/ext/autodoc/__init__.py | 2 +- sphinx/ext/autodoc/mock.py | 7 +- sphinx/util/inspect.py | 198 +++++++----- sphinx/util/typing.py | 294 ++++++++++-------- .../test_ext_autodoc_automodule.py | 16 + tests/test_util/test_util_typing.py | 5 + 7 files changed, 326 insertions(+), 198 deletions(-) diff --git a/.ruff.toml b/.ruff.toml index de42679ba9d..6884f42813c 100644 --- a/.ruff.toml +++ b/.ruff.toml @@ -542,7 +542,6 @@ exclude = [ "sphinx/util/cfamily.py", "sphinx/util/math.py", "sphinx/util/logging.py", - "sphinx/util/inspect.py", "sphinx/util/parallel.py", "sphinx/util/inventory.py", "sphinx/util/__init__.py", @@ -551,7 +550,6 @@ exclude = [ "sphinx/util/http_date.py", "sphinx/util/matching.py", "sphinx/util/index_entries.py", - "sphinx/util/typing.py", "sphinx/util/images.py", "sphinx/util/exceptions.py", "sphinx/util/requests.py", diff --git a/sphinx/ext/autodoc/__init__.py b/sphinx/ext/autodoc/__init__.py index b5cae374b0e..0758f46248a 100644 --- a/sphinx/ext/autodoc/__init__.py +++ b/sphinx/ext/autodoc/__init__.py @@ -2267,7 +2267,7 @@ def format_signature(self, **kwargs: Any) -> str: pass # default implementation. skipped. else: if inspect.isclassmethod(func): - func = func.__func__ + func = func.__func__ # type: ignore[attr-defined] dispatchmeth = self.annotate_to_first_argument(func, typ) if dispatchmeth: documenter = MethodDocumenter(self.directive, '') diff --git a/sphinx/ext/autodoc/mock.py b/sphinx/ext/autodoc/mock.py index 4036bd58826..fcef7cd9ec2 100644 --- a/sphinx/ext/autodoc/mock.py +++ b/sphinx/ext/autodoc/mock.py @@ -8,13 +8,16 @@ from importlib.abc import Loader, MetaPathFinder from importlib.machinery import ModuleSpec from types import MethodType, ModuleType -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING from sphinx.util import logging from sphinx.util.inspect import isboundmethod, safe_getattr if TYPE_CHECKING: from collections.abc import Generator, Iterator, Sequence + from typing import Any + + from typing_extensions import TypeGuard logger = logging.getLogger(__name__) @@ -153,7 +156,7 @@ def mock(modnames: list[str]) -> Generator[None, None, None]: finder.invalidate_caches() -def ismockmodule(subject: Any) -> bool: +def ismockmodule(subject: Any) -> TypeGuard[_MockModule]: """Check if the object is a mocked module.""" return isinstance(subject, _MockModule) diff --git a/sphinx/util/inspect.py b/sphinx/util/inspect.py index 654419f9b88..89889dc2b41 100644 --- a/sphinx/util/inspect.py +++ b/sphinx/util/inspect.py @@ -11,7 +11,7 @@ import sys import types import typing -from collections.abc import Mapping, Sequence +from collections.abc import Mapping from functools import cached_property, partial, partialmethod, singledispatchmethod from importlib import import_module from inspect import ( # NoQA: F401 @@ -23,19 +23,43 @@ ismodule, ) from io import StringIO -from types import ( - ClassMethodDescriptorType, - MethodDescriptorType, - MethodType, - ModuleType, - WrapperDescriptorType, -) -from typing import Any, Callable, cast +from types import ClassMethodDescriptorType, MethodDescriptorType, WrapperDescriptorType +from typing import Any, cast from sphinx.pycode.ast import unparse as ast_unparse from sphinx.util import logging from sphinx.util.typing import ForwardRef, stringify_annotation +if typing.TYPE_CHECKING: + from collections.abc import Sequence + from enum import Enum + from types import MethodType, ModuleType + from typing import Callable, Protocol, Union + + from typing_extensions import TypeGuard + + class _SupportsGet(Protocol): + def __get__(self, __instance: Any, __owner: type | None = ...) -> Any: ... # NoQA: E704 + + class _SupportsSet(Protocol): + # instance and value are contravariants but we do not need that precision + def __set__(self, __instance: Any, __value: Any) -> None: ... # NoQA: E704 + + class _SupportsDelete(Protocol): + # instance is contravariant but we do not need that precision + def __delete__(self, __instance: Any) -> None: ... # NoQA: E704 + + _RoutineType = Union[ + types.FunctionType, + types.LambdaType, + types.MethodType, + types.BuiltinFunctionType, + types.BuiltinMethodType, + types.WrapperDescriptorType, + types.MethodDescriptorType, + types.ClassMethodDescriptorType, + ] + logger = logging.getLogger(__name__) memory_address_re = re.compile(r' at 0x[0-9a-f]{8,16}(?=>)', re.IGNORECASE) @@ -165,12 +189,12 @@ def isNewType(obj: Any) -> bool: return __module__ == 'typing' and __qualname__ == 'NewType..new_type' -def isenumclass(x: Any) -> bool: +def isenumclass(x: Any) -> TypeGuard[type[Enum]]: """Check if the object is subclass of enum.""" return inspect.isclass(x) and issubclass(x, enum.Enum) -def isenumattribute(x: Any) -> bool: +def isenumattribute(x: Any) -> TypeGuard[Enum]: """Check if the object is attribute of enum.""" return isinstance(x, enum.Enum) @@ -186,7 +210,7 @@ def unpartial(obj: Any) -> Any: return obj -def ispartial(obj: Any) -> bool: +def ispartial(obj: Any) -> TypeGuard[partial | partialmethod]: """Check if the object is partial.""" return isinstance(obj, (partial, partialmethod)) @@ -222,11 +246,10 @@ def isstaticmethod(obj: Any, cls: Any = None, name: str | None = None) -> bool: return False -def isdescriptor(x: Any) -> bool: +def isdescriptor(x: Any) -> TypeGuard[_SupportsGet | _SupportsSet | _SupportsDelete]: """Check if the object is some kind of descriptor.""" return any( - callable(safe_getattr(x, item, None)) - for item in ('__get__', '__set__', '__delete__') + callable(safe_getattr(x, item, None)) for item in ('__get__', '__set__', '__delete__') ) @@ -265,46 +288,49 @@ def isattributedescriptor(obj: Any) -> bool: if inspect.isclass(unwrapped): # attribute must not be a class return False - if isinstance(unwrapped, (ClassMethodDescriptorType, - MethodDescriptorType, - WrapperDescriptorType)): + if isinstance( + unwrapped, (ClassMethodDescriptorType, MethodDescriptorType, WrapperDescriptorType) + ): # attribute must not be a method descriptor return False # attribute must not be an instancemethod (C-API) - return type(unwrapped).__name__ != "instancemethod" + return type(unwrapped).__name__ != 'instancemethod' return False def is_singledispatch_function(obj: Any) -> bool: """Check if the object is singledispatch function.""" - return (inspect.isfunction(obj) and - hasattr(obj, 'dispatch') and - hasattr(obj, 'register') and - obj.dispatch.__module__ == 'functools') + return ( + inspect.isfunction(obj) + and hasattr(obj, 'dispatch') + and hasattr(obj, 'register') + and obj.dispatch.__module__ == 'functools' + ) -def is_singledispatch_method(obj: Any) -> bool: +def is_singledispatch_method(obj: Any) -> TypeGuard[singledispatchmethod]: """Check if the object is singledispatch method.""" return isinstance(obj, singledispatchmethod) -def isfunction(obj: Any) -> bool: +def isfunction(obj: Any) -> TypeGuard[types.FunctionType]: """Check if the object is function.""" return inspect.isfunction(unpartial(obj)) -def isbuiltin(obj: Any) -> bool: +def isbuiltin(obj: Any) -> TypeGuard[types.BuiltinFunctionType]: """Check if the object is function.""" return inspect.isbuiltin(unpartial(obj)) -def isroutine(obj: Any) -> bool: +def isroutine(obj: Any) -> TypeGuard[_RoutineType]: """Check is any kind of function or method.""" return inspect.isroutine(unpartial(obj)) def iscoroutinefunction(obj: Any) -> bool: """Check if the object is coroutine-function.""" + def iswrappedcoroutine(obj: Any) -> bool: """Check if the object is wrapped coroutine-function.""" if isstaticmethod(obj) or isclassmethod(obj) or ispartial(obj): @@ -317,15 +343,14 @@ def iswrappedcoroutine(obj: Any) -> bool: return inspect.iscoroutinefunction(obj) -def isproperty(obj: Any) -> bool: +def isproperty(obj: Any) -> TypeGuard[property | cached_property]: """Check if the object is property.""" return isinstance(obj, (property, cached_property)) def isgenericalias(obj: Any) -> bool: """Check if the object is GenericAlias.""" - return isinstance( - obj, (types.GenericAlias, typing._BaseGenericAlias)) # type: ignore[attr-defined] + return isinstance(obj, (types.GenericAlias, typing._BaseGenericAlias)) # type: ignore[attr-defined] def safe_getattr(obj: Any, name: str, *defargs: Any) -> Any: @@ -366,8 +391,10 @@ def object_description(obj: Any, *, _seen: frozenset[int] = frozenset()) -> str: # Cannot sort dict keys, fall back to using descriptions as a sort key sorted_keys = sorted(obj, key=lambda k: object_description(k, _seen=seen)) - items = ((object_description(key, _seen=seen), - object_description(obj[key], _seen=seen)) for key in sorted_keys) + items = ( + (object_description(key, _seen=seen), object_description(obj[key], _seen=seen)) + for key in sorted_keys + ) return '{%s}' % ', '.join(f'{key}: {value}' for (key, value) in items) elif isinstance(obj, set): if id(obj) in seen: @@ -388,8 +415,9 @@ def object_description(obj: Any, *, _seen: frozenset[int] = frozenset()) -> str: except TypeError: # Cannot sort frozenset values, fall back to using descriptions as a sort key sorted_values = sorted(obj, key=lambda x: object_description(x, _seen=seen)) - return 'frozenset({%s})' % ', '.join(object_description(x, _seen=seen) - for x in sorted_values) + return 'frozenset({%s})' % ', '.join( + object_description(x, _seen=seen) for x in sorted_values + ) elif isinstance(obj, enum.Enum): if obj.__repr__.__func__ is not enum.Enum.__repr__: # type: ignore[attr-defined] return repr(obj) @@ -517,6 +545,7 @@ class TypeAliasNamespace(dict[str, Any]): """ def __init__(self, mapping: dict[str, str]) -> None: + super().__init__() self.__mapping = mapping def __getitem__(self, key: str) -> Any: @@ -537,12 +566,16 @@ def _should_unwrap(subject: Callable) -> bool: """Check the function should be unwrapped on getting signature.""" __globals__ = getglobals(subject) # contextmanger should be unwrapped - return (__globals__.get('__name__') == 'contextlib' and - __globals__.get('__file__') == contextlib.__file__) + return ( + __globals__.get('__name__') == 'contextlib' + and __globals__.get('__file__') == contextlib.__file__ + ) def signature( - subject: Callable, bound_method: bool = False, type_aliases: dict[str, str] | None = None, + subject: Callable, + bound_method: bool = False, + type_aliases: dict[str, str] | None = None, ) -> inspect.Signature: """Return a Signature object for the given *subject*. @@ -596,22 +629,30 @@ def signature( # # For example, this helps a function having a default value `inspect._empty`. # refs: https://github.com/sphinx-doc/sphinx/issues/7935 - return inspect.Signature(parameters, return_annotation=return_annotation, - __validate_parameters__=False) + return inspect.Signature( + parameters, return_annotation=return_annotation, __validate_parameters__=False + ) -def evaluate_signature(sig: inspect.Signature, globalns: dict[str, Any] | None = None, - localns: dict[str, Any] | None = None, - ) -> inspect.Signature: +def evaluate_signature( + sig: inspect.Signature, + globalns: dict[str, Any] | None = None, + localns: dict[str, Any] | None = None, +) -> inspect.Signature: """Evaluate unresolved type annotations in a signature object.""" + def evaluate_forwardref( - ref: ForwardRef, globalns: dict[str, Any] | None, localns: dict[str, Any] | None, + ref: ForwardRef, + globalns: dict[str, Any] | None, + localns: dict[str, Any] | None, ) -> Any: """Evaluate a forward reference.""" return ref._evaluate(globalns, localns, frozenset()) def evaluate( - annotation: Any, globalns: dict[str, Any], localns: dict[str, Any], + annotation: Any, + globalns: dict[str, Any], + localns: dict[str, Any], ) -> Any: """Evaluate unresolved type annotation.""" try: @@ -649,9 +690,12 @@ def evaluate( return sig.replace(parameters=parameters, return_annotation=return_annotation) -def stringify_signature(sig: inspect.Signature, show_annotation: bool = True, - show_return_annotation: bool = True, - unqualified_typehints: bool = False) -> str: +def stringify_signature( + sig: inspect.Signature, + show_annotation: bool = True, + show_return_annotation: bool = True, + unqualified_typehints: bool = False, +) -> str: """Stringify a Signature object. :param show_annotation: If enabled, show annotations on the signature @@ -670,9 +714,11 @@ def stringify_signature(sig: inspect.Signature, show_annotation: bool = True, if param.kind != param.POSITIONAL_ONLY and last_kind == param.POSITIONAL_ONLY: # PEP-570: Separator for Positional Only Parameter: / args.append('/') - if param.kind == param.KEYWORD_ONLY and last_kind in (param.POSITIONAL_OR_KEYWORD, - param.POSITIONAL_ONLY, - None): + if param.kind == param.KEYWORD_ONLY and last_kind in ( + param.POSITIONAL_OR_KEYWORD, + param.POSITIONAL_ONLY, + None, + ): # PEP-3102: Separator for Keyword Only Parameter: * args.append('*') @@ -702,9 +748,11 @@ def stringify_signature(sig: inspect.Signature, show_annotation: bool = True, args.append('/') concatenated_args = ', '.join(args) - if (sig.return_annotation is Parameter.empty or - show_annotation is False or - show_return_annotation is False): + if ( + sig.return_annotation is Parameter.empty + or show_annotation is False + or show_return_annotation is False + ): return f'({concatenated_args})' else: annotation = stringify_annotation(sig.return_annotation, mode) @@ -725,7 +773,7 @@ def signature_from_ast(node: ast.FunctionDef, code: str = '') -> inspect.Signatu args = node.args defaults = list(args.defaults) params = [] - if hasattr(args, "posonlyargs"): + if hasattr(args, 'posonlyargs'): posonlyargs = len(args.posonlyargs) positionals = posonlyargs + len(args.args) else: @@ -735,17 +783,19 @@ def signature_from_ast(node: ast.FunctionDef, code: str = '') -> inspect.Signatu for _ in range(len(defaults), positionals): defaults.insert(0, Parameter.empty) # type: ignore[arg-type] - if hasattr(args, "posonlyargs"): + if hasattr(args, 'posonlyargs'): for i, arg in enumerate(args.posonlyargs): if defaults[i] is Parameter.empty: default = Parameter.empty else: - default = DefaultValue( - ast_unparse(defaults[i], code)) # type: ignore[assignment] + default = DefaultValue(ast_unparse(defaults[i], code)) # type: ignore[assignment] annotation = ast_unparse(arg.annotation, code) or Parameter.empty - params.append(Parameter(arg.arg, Parameter.POSITIONAL_ONLY, - default=default, annotation=annotation)) + params.append( + Parameter( + arg.arg, Parameter.POSITIONAL_ONLY, default=default, annotation=annotation + ) + ) for i, arg in enumerate(args.args): if defaults[i + posonlyargs] is Parameter.empty: @@ -756,28 +806,34 @@ def signature_from_ast(node: ast.FunctionDef, code: str = '') -> inspect.Signatu ) annotation = ast_unparse(arg.annotation, code) or Parameter.empty - params.append(Parameter(arg.arg, Parameter.POSITIONAL_OR_KEYWORD, - default=default, annotation=annotation)) + params.append( + Parameter( + arg.arg, + Parameter.POSITIONAL_OR_KEYWORD, + default=default, + annotation=annotation, + ) + ) if args.vararg: annotation = ast_unparse(args.vararg.annotation, code) or Parameter.empty - params.append(Parameter(args.vararg.arg, Parameter.VAR_POSITIONAL, - annotation=annotation)) + params.append( + Parameter(args.vararg.arg, Parameter.VAR_POSITIONAL, annotation=annotation) + ) for i, arg in enumerate(args.kwonlyargs): if args.kw_defaults[i] is None: default = Parameter.empty else: - default = DefaultValue( - ast_unparse(args.kw_defaults[i], code)) # type: ignore[arg-type,assignment] + default = DefaultValue(ast_unparse(args.kw_defaults[i], code)) # type: ignore[arg-type,assignment] annotation = ast_unparse(arg.annotation, code) or Parameter.empty - params.append(Parameter(arg.arg, Parameter.KEYWORD_ONLY, default=default, - annotation=annotation)) + params.append( + Parameter(arg.arg, Parameter.KEYWORD_ONLY, default=default, annotation=annotation) + ) if args.kwarg: annotation = ast_unparse(args.kwarg.annotation, code) or Parameter.empty - params.append(Parameter(args.kwarg.arg, Parameter.VAR_KEYWORD, - annotation=annotation)) + params.append(Parameter(args.kwarg.arg, Parameter.VAR_KEYWORD, annotation=annotation)) return_annotation = ast_unparse(node.returns, code) or Parameter.empty @@ -799,8 +855,10 @@ def getdoc( * inherited docstring * inherited decorated methods """ + def getdoc_internal( - obj: Any, attrgetter: Callable[[Any, str, Any], Any] = safe_getattr, + obj: Any, + attrgetter: Callable[[Any, str, Any], Any] = safe_getattr, ) -> str | None: doc = attrgetter(obj, '__doc__', None) if isinstance(doc, str): diff --git a/sphinx/util/typing.py b/sphinx/util/typing.py index a31731e7bb3..821aaa6cfe2 100644 --- a/sphinx/util/typing.py +++ b/sphinx/util/typing.py @@ -8,23 +8,39 @@ 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 if TYPE_CHECKING: import enum + from collections.abc import Mapping + + from typing_extensions import TypeGuard from sphinx.application import Sphinx + class _SpecialForm(typing.Protocol): + _name: str + + if sys.version_info >= (3, 10): from types import UnionType else: UnionType = None # classes that have an incorrect .__module__ attribute -_INVALID_BUILTIN_CLASSES = { +_INVALID_BUILTIN_CLASSES: Mapping[object, str] = { Context: 'contextvars.Context', # Context.__module__ == '_contextvars' ContextVar: 'contextvars.ContextVar', # ContextVar.__module__ == '_contextvars' Token: 'contextvars.Token', # Token.__module__ == '_contextvars' @@ -71,8 +87,10 @@ def is_invalid_builtin_class(obj: Any) -> bool: PathMatcher = Callable[[str], bool] # common role functions -RoleFunction = Callable[[str, str, str, int, Inliner, dict[str, Any], Sequence[str]], - tuple[list[nodes.Node], list[nodes.system_message]]] +RoleFunction = Callable[ + [str, str, str, int, Inliner, dict[str, Any], Sequence[str]], + tuple[list[nodes.Node], list[nodes.system_message]], +] # A option spec for directive OptionSpec = dict[str, Callable[[str], Any]] @@ -115,7 +133,9 @@ class ExtensionMetadata(TypedDict, total=False): def get_type_hints( - obj: Any, globalns: dict[str, Any] | None = None, localns: dict[str, Any] | None = None, + obj: Any, + globalns: dict[str, Any] | None = None, + localns: dict[str, Any] | None = None, ) -> dict[str, Any]: """Return a dictionary containing type hints for a function, method, module or class object. @@ -147,8 +167,20 @@ def is_system_TypeVar(typ: Any) -> bool: return modname == 'typing' and isinstance(typ, TypeVar) -def restify(cls: type | None, mode: str = 'fully-qualified-except-typing') -> str: - """Convert python class to a reST reference. +def _is_special_form(obj: Any) -> TypeGuard[_SpecialForm]: + return isinstance(obj, typing._SpecialForm) + + +def _is_annotated_form(obj: Any) -> TypeGuard[Annotated[Any, ...]]: + return typing.get_origin(obj) is Annotated or str(obj) == 'typing.Annotated' + + +def _get_typing_internal_name(obj: Any) -> str | None: + return getattr(obj, '_name', None) + + +def restify(cls: Any, mode: str = 'fully-qualified-except-typing') -> str: + """Convert a python type-like object to a reST reference. :param mode: Specify a method how annotations will be stringified. @@ -161,114 +193,117 @@ def restify(cls: type | None, mode: str = 'fully-qualified-except-typing') -> st from sphinx.ext.autodoc.mock import ismock, ismockmodule # lazy loading from sphinx.util import inspect # lazy loading + if cls is None or cls is NoneType: + return ':py:obj:`None`' + + if cls is Ellipsis: + return '...' + + if isinstance(cls, str): + return cls + if mode == 'smart': modprefix = '~' else: modprefix = '' + # NOTE: if more cases must be added, carefully check that they are placed + # correctly, i.e., check that the cases are from the most precise to + # the least precise case. + try: - if cls is None or cls is NoneType: - return ':py:obj:`None`' - elif cls is Ellipsis: - return '...' - elif isinstance(cls, str): - return cls - elif ismockmodule(cls): + if ismockmodule(cls): return f':py:class:`{modprefix}{cls.__name__}`' elif ismock(cls): return f':py:class:`{modprefix}{cls.__module__}.{cls.__name__}`' - elif is_invalid_builtin_class(cls): + elif is_invalid_builtin_class(cls): # this never raises TypeError return f':py:class:`{modprefix}{_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__}`' - else: - return ':py:class:`%s`' % cls.__name__ + return f':py:class:`{cls.__name__}`' elif UnionType and isinstance(cls, UnionType): - if len(cls.__args__) > 1 and None in cls.__args__: - args = ' | '.join(restify(a, mode) for a in cls.__args__ if a) - return 'Optional[%s]' % args - else: - return ' | '.join(restify(a, mode) for a in cls.__args__) + return ' | '.join(restify(a, mode) for a in cls.__args__) elif cls.__module__ in ('__builtin__', 'builtins'): if hasattr(cls, '__args__'): + __args__: tuple[Any, ...] = cls.__args__ if not cls.__args__: # Empty tuple, list, ... - return fr':py:class:`{cls.__name__}`\ [{cls.__args__!r}]' - - concatenated_args = ', '.join(restify(arg, mode) for arg in cls.__args__) - return fr':py:class:`{cls.__name__}`\ [{concatenated_args}]' - else: - return ':py:class:`%s`' % cls.__name__ - elif (inspect.isgenericalias(cls) - and cls.__module__ == 'typing' - and cls.__origin__ is Union): # type: ignore[attr-defined] - if (len(cls.__args__) > 1 # type: ignore[attr-defined] - and cls.__args__[-1] is NoneType): # type: ignore[attr-defined] - if len(cls.__args__) > 2: # type: ignore[attr-defined] - args = ', '.join(restify(a, mode) - for a in cls.__args__[:-1]) # type: ignore[attr-defined] - return ':py:obj:`~typing.Optional`\\ [:obj:`~typing.Union`\\ [%s]]' % args - else: - return ':py:obj:`~typing.Optional`\\ [%s]' % restify( - cls.__args__[0], mode) # type: ignore[attr-defined] - else: - args = ', '.join(restify(a, mode) - for a in cls.__args__) # type: ignore[attr-defined] - return ':py:obj:`~typing.Union`\\ [%s]' % args + return rf':py:class:`{cls.__name__}`\ [{__args__!r}]' + + concatenated_args = ', '.join(restify(arg, mode) for arg in __args__) + return rf':py:class:`{cls.__name__}`\ [{concatenated_args}]' + return f':py:class:`{cls.__name__}`' + elif ( + inspect.isgenericalias(cls) + and cls.__module__ == 'typing' + and cls.__origin__ is Union + ): + # *cls* is defined in ``typing``, and thus ``__args__`` must exist + if NoneType in (__args__ := cls.__args__): + # Union[T_1, ..., T_k, None, T_{k+1}, ..., T_n] + non_none = [a for a in __args__ if a is not NoneType] + if len(non_none) == 1: + return rf':py:obj:`~typing.Optional`\ [{restify(non_none[0], mode)}]' + args = ', '.join(restify(a, mode) for a in non_none) + return rf':py:obj:`~typing.Optional`\ [:obj:`~typing.Union`\ [{args}]]' + + args = ', '.join(restify(a, mode) for a in __args__) + return rf':py:obj:`~typing.Union`\ [{args}]' elif inspect.isgenericalias(cls): - if isinstance(cls.__origin__, typing._SpecialForm): # type: ignore[attr-defined] - text = restify(cls.__origin__, mode) # type: ignore[attr-defined,arg-type] - elif getattr(cls, '_name', None): - cls_name = cls._name # type: ignore[attr-defined] - if cls.__module__ == 'typing': - text = f':py:class:`~{cls.__module__}.{cls_name}`' - else: - text = f':py:class:`{modprefix}{cls.__module__}.{cls_name}`' + # A generic alias always has an __origin__, but it is difficult to + # use a type guard on inspect.isgenericalias() (ideally, we would + # like to use ``TypeIs`` introduced in Python 3.13+). + if _is_special_form(__origin__ := cls.__origin__): + text = restify(__origin__, mode) + elif internal_name := _get_typing_internal_name(cls): + prefix = '~' if cls.__module__ == 'typing' else modprefix + text = f':py:class:`{prefix}{cls.__module__}.{internal_name}`' else: - text = restify(cls.__origin__, mode) # type: ignore[attr-defined] - - 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'): # type: ignore[attr-defined] - 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': - literal_args = [] - for a in cls.__args__: - if inspect.isenumattribute(a): - literal_args.append(_format_literal_enum_arg(a, mode=mode)) - else: - literal_args.append(repr(a)) - text += r"\ [%s]" % ', '.join(literal_args) - del literal_args - elif cls.__args__: - text += r"\ [%s]" % ", ".join(restify(a, mode) for a in cls.__args__) - - return text - elif isinstance(cls, typing._SpecialForm): + text = restify(__origin__, mode) + + if not (__args__ := getattr(cls, '__args__', ())): + return text + + if all(map(is_system_TypeVar, __args__)): + # do not print the arguments they are all type variables + return text + + params: str | None = None + + if cls.__module__ == 'typing': + if _get_typing_internal_name(cls) == 'Callable': + vargs = ', '.join(restify(a, mode) for a in __args__[:-1]) + rtype = restify(__args__[-1], mode) + params = f'[{vargs}], {rtype}' + elif _get_typing_internal_name(__origin__) == 'Literal': + literal_args = [] + for a in __args__: + if inspect.isenumattribute(a): + literal_args.append(_format_literal_enum_arg(a, mode=mode)) + else: + literal_args.append(repr(a)) + params = ', '.join(literal_args) + + if params is None: + # generic representation of the parameters + params = ', '.join(restify(a, mode) for a in __args__) + + return rf'{text}\ [{params}]' + elif _is_special_form(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__'): - if cls.__module__ == 'typing': - return f':py:class:`~{cls.__module__}.{cls.__qualname__}`' - else: - return f':py:class:`{modprefix}{cls.__module__}.{cls.__qualname__}`' + prefix = '~' if cls.__module__ == 'typing' else modprefix + return f':py:class:`{prefix}{cls.__module__}.{cls.__qualname__}`' elif isinstance(cls, ForwardRef): - return ':py:class:`%s`' % cls.__forward_arg__ + return f':py:class:`{cls.__forward_arg__}`' else: - # not a class (ex. TypeVar) - if cls.__module__ == 'typing': - return f':py:obj:`~{cls.__module__}.{cls.__name__}`' - else: - return f':py:obj:`{modprefix}{cls.__module__}.{cls.__name__}`' + # not a class (ex. TypeVar) but should have a __name__ + prefix = '~' if cls.__module__ == 'typing' else modprefix + return f':py:obj:`{prefix}{cls.__module__}.{cls.__name__}`' except (AttributeError, TypeError): return inspect.object_description(cls) @@ -295,10 +330,26 @@ def stringify_annotation( from sphinx.util.inspect import isNewType # lazy loading if mode not in {'fully-qualified-except-typing', 'fully-qualified', 'smart'}: - msg = ("'mode' must be one of 'fully-qualified-except-typing', " - f"'fully-qualified', or 'smart'; got {mode!r}.") + msg = ( + "'mode' must be one of 'fully-qualified-except-typing', " + f"'fully-qualified', or 'smart'; got {mode!r}." + ) raise ValueError(msg) + if isinstance(annotation, str): + if annotation.startswith("'") and annotation.endswith("'"): + # might be a double Forward-ref'ed type. Go unquoting. + return annotation[1:-1] + return annotation + + # literal annotations + if not annotation: + return repr(annotation) + elif annotation is NoneType: + return 'None' + elif annotation is Ellipsis: + return '...' + if mode == 'smart': module_prefix = '~' else: @@ -309,46 +360,41 @@ def stringify_annotation( annotation_name = getattr(annotation, '__name__', '') annotation_module_is_typing = annotation_module == 'typing' - if isinstance(annotation, str): - if annotation.startswith("'") and annotation.endswith("'"): - # might be a double Forward-ref'ed type. Go unquoting. - return annotation[1:-1] - else: - return annotation - elif isinstance(annotation, TypeVar): + # extract the annotation's base type by considering some special cases + # that can be formatted directly + + if isinstance(annotation, TypeVar): if annotation_module_is_typing and mode in {'fully-qualified-except-typing', 'smart'}: return annotation_name - else: - return module_prefix + f'{annotation_module}.{annotation_name}' + return f'{module_prefix}{annotation_module}.{annotation_name}' elif isNewType(annotation): if sys.version_info[:2] >= (3, 10): # newtypes have correct module info since Python 3.10+ - return module_prefix + f'{annotation_module}.{annotation_name}' - else: - return annotation_name - elif not annotation: - return repr(annotation) - elif annotation is NoneType: - return 'None' + return f'{module_prefix}{annotation_module}.{annotation_name}' + return annotation_name + # mock annotations elif ismockmodule(annotation): return module_prefix + annotation_name elif ismock(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 py310+ + # special cases + elif _is_annotated_form(annotation): # for py39+ pass elif annotation_module == 'builtins' and annotation_qualname: - if (args := getattr(annotation, '__args__', None)) is not None: # 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: + if (args := getattr(annotation, '__args__', None)) is None: return annotation_qualname - elif annotation is Ellipsis: - return '...' + + # 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: + # add other special cases here + pass module_prefix = f'{annotation_module}.' annotation_forward_arg = getattr(annotation, '__forward_arg__', None) @@ -365,14 +411,15 @@ def stringify_annotation( # handle ForwardRefs qualname = annotation_forward_arg else: - _name = getattr(annotation, '_name', '') - if _name: - qualname = _name + if internal_name := _get_typing_internal_name(annotation): + qualname = internal_name elif annotation_qualname: qualname = annotation_qualname else: + # in this case, we know that the annotation is a member + # of :mod:`typing` and all of them define ``__origin__`` qualname = stringify_annotation( - annotation.__origin__, 'fully-qualified-except-typing', + annotation.__origin__, 'fully-qualified-except-typing' ).replace('typing.', '') # ex. Union elif annotation_qualname: qualname = annotation_qualname @@ -386,6 +433,8 @@ def stringify_annotation( # only make them appear twice return repr(annotation) + # process the argument's part + annotation_args = getattr(annotation, '__args__', None) if annotation_args: if not isinstance(annotation_args, (list, tuple)): @@ -414,7 +463,7 @@ def format_literal_arg(arg: Any) -> str: args = ', '.join(map(format_literal_arg, 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]) @@ -430,8 +479,7 @@ def _format_literal_enum_arg(arg: enum.Enum, /, *, mode: str) -> str: enum_cls = arg.__class__ if mode == 'smart' or enum_cls.__module__ == 'typing': return f':py:attr:`~{enum_cls.__module__}.{enum_cls.__qualname__}.{arg.name}`' - else: - return f':py:attr:`{enum_cls.__module__}.{enum_cls.__qualname__}.{arg.name}`' + return f':py:attr:`{enum_cls.__module__}.{enum_cls.__qualname__}.{arg.name}`' # deprecated name -> (object to return, canonical path or empty string, removal version) diff --git a/tests/test_extensions/test_ext_autodoc_automodule.py b/tests/test_extensions/test_ext_autodoc_automodule.py index 92565aef058..c6ced7eebb1 100644 --- a/tests/test_extensions/test_ext_autodoc_automodule.py +++ b/tests/test_extensions/test_ext_autodoc_automodule.py @@ -4,7 +4,9 @@ source file translated by test_build. """ +import inspect import sys +import typing import pytest @@ -185,8 +187,22 @@ def test_automodule_inherited_members(app): 'sphinx.missing_module4']}) @pytest.mark.usefixtures("rollback_sysmodules") def test_subclass_of_mocked_object(app): + from sphinx.ext.autodoc.mock import _MockObject sys.modules.pop('target', None) # unload target module to clear the module cache + options = {'members': None} + actual = do_autodoc(app, 'module', 'target.need_mocks', options) + # ``typing.Any`` is not available at runtime on ``_MockObject.__new__`` + assert '.. py:class:: Inherited(*args: Any, **kwargs: Any)' in actual + + # make ``typing.Any`` available at runtime on ``_MockObject.__new__`` + sig = inspect.signature(_MockObject.__new__) + parameters = sig.parameters.copy() + for name in ('args', 'kwargs'): + parameters[name] = parameters[name].replace(annotation=typing.Any) + sig = sig.replace(parameters=tuple(parameters.values())) + _MockObject.__new__.__signature__ = sig # type: ignore[attr-defined] + options = {'members': None} actual = do_autodoc(app, 'module', 'target.need_mocks', options) assert '.. py:class:: Inherited(*args: ~typing.Any, **kwargs: ~typing.Any)' in actual diff --git a/tests/test_util/test_util_typing.py b/tests/test_util/test_util_typing.py index 2e90d71684d..f609450cefa 100644 --- a/tests/test_util/test_util_typing.py +++ b/tests/test_util/test_util_typing.py @@ -189,6 +189,7 @@ def test_restify_type_hints_Callable(): def test_restify_type_hints_Union(): assert restify(Optional[int]) == ":py:obj:`~typing.Optional`\\ [:py:class:`int`]" assert restify(Union[str, None]) == ":py:obj:`~typing.Optional`\\ [:py:class:`str`]" + assert restify(Union[None, str]) == ":py:obj:`~typing.Optional`\\ [:py:class:`str`]" assert restify(Union[int, str]) == (":py:obj:`~typing.Union`\\ " "[:py:class:`int`, :py:class:`str`]") assert restify(Union[int, Integral]) == (":py:obj:`~typing.Union`\\ " @@ -297,6 +298,7 @@ def test_restify_pep_585(): @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] + assert restify(None | int) == ":py:obj:`None` | :py:class:`int`" # type: ignore[attr-defined] assert restify(int | str) == ":py:class:`int` | :py:class:`str`" # type: ignore[attr-defined] assert restify(int | str | None) == (":py:class:`int` | :py:class:`str` | " # type: ignore[attr-defined] ":py:obj:`None`") @@ -480,8 +482,11 @@ def test_stringify_type_hints_Union(): assert stringify_annotation(Optional[int], "smart") == "int | None" assert stringify_annotation(Union[str, None], 'fully-qualified-except-typing') == "str | None" + assert stringify_annotation(Union[None, str], 'fully-qualified-except-typing') == "None | str" assert stringify_annotation(Union[str, None], "fully-qualified") == "str | None" + assert stringify_annotation(Union[None, str], "fully-qualified") == "None | str" assert stringify_annotation(Union[str, None], "smart") == "str | None" + assert stringify_annotation(Union[None, str], "smart") == "None | str" assert stringify_annotation(Union[int, str], 'fully-qualified-except-typing') == "int | str" assert stringify_annotation(Union[int, str], "fully-qualified") == "int | str" From d0a2906a1a17c1a74f2cfc752ed7f290e10864ab 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, 9 Apr 2024 11:08:00 +0200 Subject: [PATCH 02/11] cleanup inspect module --- .ruff.toml | 1 - sphinx/util/inspect.py | 280 +++++++++++++++++++++-------------------- 2 files changed, 144 insertions(+), 137 deletions(-) diff --git a/.ruff.toml b/.ruff.toml index de42679ba9d..f8ccfd8ac04 100644 --- a/.ruff.toml +++ b/.ruff.toml @@ -542,7 +542,6 @@ exclude = [ "sphinx/util/cfamily.py", "sphinx/util/math.py", "sphinx/util/logging.py", - "sphinx/util/inspect.py", "sphinx/util/parallel.py", "sphinx/util/inventory.py", "sphinx/util/__init__.py", diff --git a/sphinx/util/inspect.py b/sphinx/util/inspect.py index 654419f9b88..8cabc5ef51c 100644 --- a/sphinx/util/inspect.py +++ b/sphinx/util/inspect.py @@ -11,41 +11,40 @@ import sys import types import typing -from collections.abc import Mapping, Sequence +from collections.abc import Mapping from functools import cached_property, partial, partialmethod, singledispatchmethod from importlib import import_module -from inspect import ( # NoQA: F401 - Parameter, - isasyncgenfunction, - isclass, - ismethod, - ismethoddescriptor, - ismodule, -) +from inspect import Parameter, isclass from io import StringIO -from types import ( - ClassMethodDescriptorType, - MethodDescriptorType, - MethodType, - ModuleType, - WrapperDescriptorType, -) -from typing import Any, Callable, cast +from types import ClassMethodDescriptorType, MethodDescriptorType, WrapperDescriptorType +from typing import TYPE_CHECKING, Any from sphinx.pycode.ast import unparse as ast_unparse from sphinx.util import logging from sphinx.util.typing import ForwardRef, stringify_annotation +if TYPE_CHECKING: + from collections.abc import Callable, Sequence + from types import MethodType, ModuleType + logger = logging.getLogger(__name__) memory_address_re = re.compile(r' at 0x[0-9a-f]{8,16}(?=>)', re.IGNORECASE) +# re-export +isasyncgenfunction = inspect.isasyncgenfunction +ismethod = inspect.ismethod +ismethoddescriptor = inspect.ismethoddescriptor +isclass = inspect.isclass # NoQA: F811 +ismodule = inspect.ismodule + def unwrap(obj: Any) -> Any: """Get an original object from wrapped object (wrapped functions).""" if hasattr(obj, '__sphinx_mock__'): # Skip unwrapping mock object to avoid RecursionError return obj + try: return inspect.unwrap(obj) except ValueError: @@ -61,6 +60,7 @@ def unwrap_all(obj: Any, *, stop: Callable[[Any], bool] | None = None) -> Any: while True: if stop and stop(obj): return obj + if ispartial(obj): obj = obj.func elif inspect.isroutine(obj) and hasattr(obj, '__wrapped__'): @@ -96,8 +96,7 @@ def getannotations(obj: Any) -> Mapping[str, Any]: __annotations__ = __dict__.get('__annotations__', None) if isinstance(__annotations__, Mapping): return __annotations__ - else: - return {} + return {} def getglobals(obj: Any) -> Mapping[str, Any]: @@ -105,8 +104,7 @@ def getglobals(obj: Any) -> Mapping[str, Any]: __globals__ = safe_getattr(obj, '__globals__', None) if isinstance(__globals__, Mapping): return __globals__ - else: - return {} + return {} def getmro(obj: Any) -> tuple[type, ...]: @@ -114,8 +112,7 @@ def getmro(obj: Any) -> tuple[type, ...]: __mro__ = safe_getattr(obj, '__mro__', None) if isinstance(__mro__, tuple): return __mro__ - else: - return () + return () def getorigbases(obj: Any) -> tuple[Any, ...] | None: @@ -129,8 +126,7 @@ def getorigbases(obj: Any) -> tuple[Any, ...] | None: __orig_bases__ = __dict__.get('__orig_bases__') if isinstance(__orig_bases__, tuple) and len(__orig_bases__) > 0: return __orig_bases__ - else: - return None + return None def getslots(obj: Any) -> dict[str, Any] | None: @@ -182,7 +178,6 @@ def unpartial(obj: Any) -> Any: """ while ispartial(obj): obj = obj.func - return obj @@ -198,12 +193,12 @@ def isclassmethod(obj: Any, cls: Any = None, name: str | None = None) -> bool: if inspect.ismethod(obj) and obj.__self__ is not None and isclass(obj.__self__): return True if cls and name: - placeholder = object() + # trace __mro__ if the method is defined in parent class + sentinel = object() for basecls in getmro(cls): - meth = basecls.__dict__.get(name, placeholder) - if meth is not placeholder: + meth = basecls.__dict__.get(name, sentinel) + if meth is not sentinel: return isclassmethod(meth) - return False @@ -213,11 +208,10 @@ def isstaticmethod(obj: Any, cls: Any = None, name: str | None = None) -> bool: return True if cls and name: # trace __mro__ if the method is defined in parent class - # - # .. note:: This only works well with new style classes. + sentinel = object() for basecls in getattr(cls, '__mro__', [cls]): - meth = basecls.__dict__.get(name) - if meth: + meth = basecls.__dict__.get(name, sentinel) + if meth is not sentinel: return isinstance(meth, staticmethod) return False @@ -225,8 +219,7 @@ def isstaticmethod(obj: Any, cls: Any = None, name: str | None = None) -> bool: def isdescriptor(x: Any) -> bool: """Check if the object is some kind of descriptor.""" return any( - callable(safe_getattr(x, item, None)) - for item in ('__get__', '__set__', '__delete__') + callable(safe_getattr(x, item, None)) for item in ('__get__', '__set__', '__delete__') ) @@ -265,22 +258,24 @@ def isattributedescriptor(obj: Any) -> bool: if inspect.isclass(unwrapped): # attribute must not be a class return False - if isinstance(unwrapped, (ClassMethodDescriptorType, - MethodDescriptorType, - WrapperDescriptorType)): + if isinstance( + unwrapped, (ClassMethodDescriptorType, MethodDescriptorType, WrapperDescriptorType) + ): # attribute must not be a method descriptor return False # attribute must not be an instancemethod (C-API) - return type(unwrapped).__name__ != "instancemethod" + return type(unwrapped).__name__ != 'instancemethod' return False def is_singledispatch_function(obj: Any) -> bool: """Check if the object is singledispatch function.""" - return (inspect.isfunction(obj) and - hasattr(obj, 'dispatch') and - hasattr(obj, 'register') and - obj.dispatch.__module__ == 'functools') + return ( + inspect.isfunction(obj) + and hasattr(obj, 'dispatch') + and hasattr(obj, 'register') + and obj.dispatch.__module__ == 'functools' + ) def is_singledispatch_method(obj: Any) -> bool: @@ -305,6 +300,7 @@ def isroutine(obj: Any) -> bool: def iscoroutinefunction(obj: Any) -> bool: """Check if the object is coroutine-function.""" + def iswrappedcoroutine(obj: Any) -> bool: """Check if the object is wrapped coroutine-function.""" if isstaticmethod(obj) or isclassmethod(obj) or ispartial(obj): @@ -324,8 +320,7 @@ def isproperty(obj: Any) -> bool: def isgenericalias(obj: Any) -> bool: """Check if the object is GenericAlias.""" - return isinstance( - obj, (types.GenericAlias, typing._BaseGenericAlias)) # type: ignore[attr-defined] + return isinstance(obj, (types.GenericAlias, typing._BaseGenericAlias)) # type: ignore[attr-defined] def safe_getattr(obj: Any, name: str, *defargs: Any) -> Any: @@ -366,8 +361,10 @@ def object_description(obj: Any, *, _seen: frozenset[int] = frozenset()) -> str: # Cannot sort dict keys, fall back to using descriptions as a sort key sorted_keys = sorted(obj, key=lambda k: object_description(k, _seen=seen)) - items = ((object_description(key, _seen=seen), - object_description(obj[key], _seen=seen)) for key in sorted_keys) + items = ( + (object_description(key, _seen=seen), object_description(obj[key], _seen=seen)) + for key in sorted_keys + ) return '{%s}' % ', '.join(f'{key}: {value}' for (key, value) in items) elif isinstance(obj, set): if id(obj) in seen: @@ -388,8 +385,9 @@ def object_description(obj: Any, *, _seen: frozenset[int] = frozenset()) -> str: except TypeError: # Cannot sort frozenset values, fall back to using descriptions as a sort key sorted_values = sorted(obj, key=lambda x: object_description(x, _seen=seen)) - return 'frozenset({%s})' % ', '.join(object_description(x, _seen=seen) - for x in sorted_values) + return 'frozenset({%s})' % ', '.join( + object_description(x, _seen=seen) for x in sorted_values + ) elif isinstance(obj, enum.Enum): if obj.__repr__.__func__ is not enum.Enum.__repr__: # type: ignore[attr-defined] return repr(obj) @@ -419,7 +417,7 @@ def object_description(obj: Any, *, _seen: frozenset[int] = frozenset()) -> str: def is_builtin_class_method(obj: Any, attr_name: str) -> bool: - """If attr_name is implemented at builtin class, return True. + """attr_name is implemented at builtin class, return True. >>> is_builtin_class_method(int, '__init__') True @@ -427,8 +425,9 @@ def is_builtin_class_method(obj: Any, attr_name: str) -> bool: Why this function needed? CPython implements int.__init__ by Descriptor but PyPy implements it by pure Python code. """ + mro = getmro(obj) + try: - mro = getmro(obj) cls = next(c for c in mro if attr_name in safe_getattr(c, '__dict__', {})) except StopIteration: return False @@ -516,7 +515,8 @@ class TypeAliasNamespace(dict[str, Any]): This enables to look up nested modules and classes like `mod1.mod2.Class`. """ - def __init__(self, mapping: dict[str, str]) -> None: + def __init__(self, mapping: Mapping[str, str]) -> None: + super().__init__() self.__mapping = mapping def __getitem__(self, key: str) -> Any: @@ -537,12 +537,16 @@ def _should_unwrap(subject: Callable) -> bool: """Check the function should be unwrapped on getting signature.""" __globals__ = getglobals(subject) # contextmanger should be unwrapped - return (__globals__.get('__name__') == 'contextlib' and - __globals__.get('__file__') == contextlib.__file__) + return ( + __globals__.get('__name__') == 'contextlib' + and __globals__.get('__file__') == contextlib.__file__ + ) def signature( - subject: Callable, bound_method: bool = False, type_aliases: dict[str, str] | None = None, + subject: Callable, + bound_method: bool = False, + type_aliases: Mapping[str, str] | None = None, ) -> inspect.Signature: """Return a Signature object for the given *subject*. @@ -596,22 +600,30 @@ def signature( # # For example, this helps a function having a default value `inspect._empty`. # refs: https://github.com/sphinx-doc/sphinx/issues/7935 - return inspect.Signature(parameters, return_annotation=return_annotation, - __validate_parameters__=False) + return inspect.Signature( + parameters, return_annotation=return_annotation, __validate_parameters__=False + ) -def evaluate_signature(sig: inspect.Signature, globalns: dict[str, Any] | None = None, - localns: dict[str, Any] | None = None, - ) -> inspect.Signature: +def evaluate_signature( + sig: inspect.Signature, + globalns: dict[str, Any] | None = None, + localns: dict[str, Any] | None = None, +) -> inspect.Signature: """Evaluate unresolved type annotations in a signature object.""" + def evaluate_forwardref( - ref: ForwardRef, globalns: dict[str, Any] | None, localns: dict[str, Any] | None, + ref: ForwardRef, + globalns: dict[str, Any] | None, + localns: dict[str, Any] | None, ) -> Any: """Evaluate a forward reference.""" return ref._evaluate(globalns, localns, frozenset()) def evaluate( - annotation: Any, globalns: dict[str, Any], localns: dict[str, Any], + annotation: Any, + globalns: dict[str, Any], + localns: dict[str, Any], ) -> Any: """Evaluate unresolved type annotation.""" try: @@ -649,9 +661,12 @@ def evaluate( return sig.replace(parameters=parameters, return_annotation=return_annotation) -def stringify_signature(sig: inspect.Signature, show_annotation: bool = True, - show_return_annotation: bool = True, - unqualified_typehints: bool = False) -> str: +def stringify_signature( + sig: inspect.Signature, + show_annotation: bool = True, + show_return_annotation: bool = True, + unqualified_typehints: bool = False, +) -> str: """Stringify a Signature object. :param show_annotation: If enabled, show annotations on the signature @@ -664,31 +679,35 @@ def stringify_signature(sig: inspect.Signature, show_annotation: bool = True, else: mode = 'fully-qualified' + EMPTY = Parameter.empty + args = [] last_kind = None for param in sig.parameters.values(): - if param.kind != param.POSITIONAL_ONLY and last_kind == param.POSITIONAL_ONLY: + if param.kind != Parameter.POSITIONAL_ONLY and last_kind == Parameter.POSITIONAL_ONLY: # PEP-570: Separator for Positional Only Parameter: / args.append('/') - if param.kind == param.KEYWORD_ONLY and last_kind in (param.POSITIONAL_OR_KEYWORD, - param.POSITIONAL_ONLY, - None): + if param.kind == Parameter.KEYWORD_ONLY and last_kind in ( + Parameter.POSITIONAL_OR_KEYWORD, + Parameter.POSITIONAL_ONLY, + None, + ): # PEP-3102: Separator for Keyword Only Parameter: * args.append('*') arg = StringIO() - if param.kind == param.VAR_POSITIONAL: + if param.kind is Parameter.VAR_POSITIONAL: arg.write('*' + param.name) - elif param.kind == param.VAR_KEYWORD: + elif param.kind is Parameter.VAR_KEYWORD: arg.write('**' + param.name) else: arg.write(param.name) - if show_annotation and param.annotation is not param.empty: + if show_annotation and param.annotation is not EMPTY: arg.write(': ') arg.write(stringify_annotation(param.annotation, mode)) - if param.default is not param.empty: - if show_annotation and param.annotation is not param.empty: + if param.default is not EMPTY: + if show_annotation and param.annotation is not EMPTY: arg.write(' = ') else: arg.write('=') @@ -697,14 +716,12 @@ def stringify_signature(sig: inspect.Signature, show_annotation: bool = True, args.append(arg.getvalue()) last_kind = param.kind - if last_kind == Parameter.POSITIONAL_ONLY: + if last_kind is Parameter.POSITIONAL_ONLY: # PEP-570: Separator for Positional Only Parameter: / args.append('/') concatenated_args = ', '.join(args) - if (sig.return_annotation is Parameter.empty or - show_annotation is False or - show_return_annotation is False): + if sig.return_annotation is EMPTY or not show_annotation or not show_return_annotation: return f'({concatenated_args})' else: annotation = stringify_annotation(sig.return_annotation, mode) @@ -715,71 +732,62 @@ def signature_from_str(signature: str) -> inspect.Signature: """Create a Signature object from string.""" code = 'def func' + signature + ': pass' module = ast.parse(code) - function = cast(ast.FunctionDef, module.body[0]) + function = typing.cast(ast.FunctionDef, module.body[0]) return signature_from_ast(function, code) def signature_from_ast(node: ast.FunctionDef, code: str = '') -> inspect.Signature: """Create a Signature object from AST *node*.""" - args = node.args - defaults = list(args.defaults) - params = [] - if hasattr(args, "posonlyargs"): - posonlyargs = len(args.posonlyargs) - positionals = posonlyargs + len(args.args) - else: - posonlyargs = 0 - positionals = len(args.args) - - for _ in range(len(defaults), positionals): - defaults.insert(0, Parameter.empty) # type: ignore[arg-type] - - if hasattr(args, "posonlyargs"): - for i, arg in enumerate(args.posonlyargs): - if defaults[i] is Parameter.empty: - default = Parameter.empty - else: - default = DefaultValue( - ast_unparse(defaults[i], code)) # type: ignore[assignment] - - annotation = ast_unparse(arg.annotation, code) or Parameter.empty - params.append(Parameter(arg.arg, Parameter.POSITIONAL_ONLY, - default=default, annotation=annotation)) - - for i, arg in enumerate(args.args): - if defaults[i + posonlyargs] is Parameter.empty: - default = Parameter.empty - else: - default = DefaultValue( - ast_unparse(defaults[i + posonlyargs], code), # type: ignore[assignment] - ) - - annotation = ast_unparse(arg.annotation, code) or Parameter.empty - params.append(Parameter(arg.arg, Parameter.POSITIONAL_OR_KEYWORD, - default=default, annotation=annotation)) - + EMPTY: type[Any] = Parameter.empty + + args: ast.arguments = node.args + default_expressions: tuple[ast.expr | None, ...] = tuple(args.defaults) + pos_only_offset = len(args.posonlyargs) + defaults_offset = pos_only_offset + len(args.args) - len(args.defaults) + # The sequence ``D = args.defaults`` contains non-None AST expressions, + # so we can use ``None`` as a sentinel value for that to indicate that + # there is no default value for a specific parameter. + # + # Let *p* be the number of positional-only and positional-or-keyword + # arguments. Note that ``0 <= len(D) <= p`` and ``D[0]`` is the default + # value corresponding to a positional-only *or* a positional-or-keyword + # argument. Since a non-default argument cannot follow a default argument, + # the sequence *D* can be completed on the left by adding None sentinels + # so that ``len(D) == p`` and ``D[i]`` is the *i*-th default argument. + default_expressions = (None,) * defaults_offset + default_expressions + + # construct the parameters list + params: list[Parameter] = [] + + # The real type of a parameter's kind is ``inspect._ParameterKind`` + # but this (integral) enumeration is not part of the public API. + def process_arg(kind: Any, arg: ast.arg, *, defexpr: ast.expr | None = None) -> None: + default: Any = EMPTY if defexpr is None else DefaultValue(ast_unparse(defexpr, code)) + annotation = ast_unparse(arg.annotation, code) or EMPTY + params.append(Parameter(arg.arg, kind, default=default, annotation=annotation)) + + # positional-only arguments (introduced in Python 3.8) + for arg, defexpr in zip(args.posonlyargs, default_expressions): + process_arg(Parameter.POSITIONAL_ONLY, arg, defexpr=defexpr) + + # normal arguments + for arg, defexpr in zip(args.args, default_expressions[pos_only_offset:]): + process_arg(Parameter.POSITIONAL_OR_KEYWORD, arg, defexpr=defexpr) + + # variadic positional argument (no possible default expression) if args.vararg: - annotation = ast_unparse(args.vararg.annotation, code) or Parameter.empty - params.append(Parameter(args.vararg.arg, Parameter.VAR_POSITIONAL, - annotation=annotation)) + process_arg(Parameter.VAR_POSITIONAL, args.vararg, defexpr=None) - for i, arg in enumerate(args.kwonlyargs): - if args.kw_defaults[i] is None: - default = Parameter.empty - else: - default = DefaultValue( - ast_unparse(args.kw_defaults[i], code)) # type: ignore[arg-type,assignment] - annotation = ast_unparse(arg.annotation, code) or Parameter.empty - params.append(Parameter(arg.arg, Parameter.KEYWORD_ONLY, default=default, - annotation=annotation)) + # keyword-only arguments + for arg, defexpr in zip(args.kwonlyargs, args.kw_defaults): + process_arg(Parameter.KEYWORD_ONLY, arg, defexpr=defexpr) + # variadic keyword argument (no possible default expression) if args.kwarg: - annotation = ast_unparse(args.kwarg.annotation, code) or Parameter.empty - params.append(Parameter(args.kwarg.arg, Parameter.VAR_KEYWORD, - annotation=annotation)) + process_arg(Parameter.VAR_KEYWORD, args.kwarg, defexpr=None) - return_annotation = ast_unparse(node.returns, code) or Parameter.empty + return_annotation = ast_unparse(node.returns, code) or EMPTY return inspect.Signature(params, return_annotation=return_annotation) @@ -799,14 +807,14 @@ def getdoc( * inherited docstring * inherited decorated methods """ + def getdoc_internal( - obj: Any, attrgetter: Callable[[Any, str, Any], Any] = safe_getattr, + obj: Any, attrgetter: Callable[[Any, str, Any], Any] = safe_getattr ) -> str | None: doc = attrgetter(obj, '__doc__', None) if isinstance(doc, str): return doc - else: - return None + return None if cls and name and isclassmethod(obj, cls, name): for basecls in getmro(cls): From bd0bb36c302f817a982004b92945a45baf75c89d 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, 9 Apr 2024 11:13:16 +0200 Subject: [PATCH 03/11] fixup --- sphinx/util/inspect.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sphinx/util/inspect.py b/sphinx/util/inspect.py index 8cabc5ef51c..1f48b260bf8 100644 --- a/sphinx/util/inspect.py +++ b/sphinx/util/inspect.py @@ -417,7 +417,7 @@ def object_description(obj: Any, *, _seen: frozenset[int] = frozenset()) -> str: def is_builtin_class_method(obj: Any, attr_name: str) -> bool: - """attr_name is implemented at builtin class, return True. + """If attr_name is implemented at builtin class, return True. >>> is_builtin_class_method(int, '__init__') True From 0a790522ba334887f6aeaf624446735edf75738b 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, 9 Apr 2024 11:18:48 +0200 Subject: [PATCH 04/11] fixup --- sphinx/util/inspect.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sphinx/util/inspect.py b/sphinx/util/inspect.py index 31f0455f2eb..26c8ef805fa 100644 --- a/sphinx/util/inspect.py +++ b/sphinx/util/inspect.py @@ -27,7 +27,7 @@ from collections.abc import Callable, Sequence from enum import Enum from types import MethodType, ModuleType - from typing import Callable, Protocol, Union + from typing import Protocol, Union from typing_extensions import TypeGuard From 0230c71b7366029815f36e6f5e9d3e34dce9e44f 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, 9 Apr 2024 12:15:58 +0200 Subject: [PATCH 05/11] fixup --- sphinx/util/inspect.py | 209 ++++++++++++++++++++++++----------------- 1 file changed, 123 insertions(+), 86 deletions(-) diff --git a/sphinx/util/inspect.py b/sphinx/util/inspect.py index 1f48b260bf8..efde17b06e8 100644 --- a/sphinx/util/inspect.py +++ b/sphinx/util/inspect.py @@ -14,7 +14,7 @@ from collections.abc import Mapping from functools import cached_property, partial, partialmethod, singledispatchmethod from importlib import import_module -from inspect import Parameter, isclass +from inspect import Parameter, Signature from io import StringIO from types import ClassMethodDescriptorType, MethodDescriptorType, WrapperDescriptorType from typing import TYPE_CHECKING, Any @@ -25,22 +25,26 @@ if TYPE_CHECKING: from collections.abc import Callable, Sequence + from inspect import _ParameterKind from types import MethodType, ModuleType logger = logging.getLogger(__name__) memory_address_re = re.compile(r' at 0x[0-9a-f]{8,16}(?=>)', re.IGNORECASE) -# re-export +# re-export as is isasyncgenfunction = inspect.isasyncgenfunction ismethod = inspect.ismethod ismethoddescriptor = inspect.ismethoddescriptor -isclass = inspect.isclass # NoQA: F811 +isclass = inspect.isclass ismodule = inspect.ismodule def unwrap(obj: Any) -> Any: - """Get an original object from wrapped object (wrapped functions).""" + """Get an original object from wrapped object (wrapped functions). + + Mocked objects are returned as is. + """ if hasattr(obj, '__sphinx_mock__'): # Skip unwrapping mock object to avoid RecursionError return obj @@ -53,14 +57,27 @@ def unwrap(obj: Any) -> Any: def unwrap_all(obj: Any, *, stop: Callable[[Any], bool] | None = None) -> Any: + """Get an original object from wrapped object. + + Unlike :func:`unwrap`, this unwraps partial functions, wrapped functions, + class methods and static methods. + + When specified, *stop* is a predicate indicating whether an object should + be unwrapped or not. """ - Get an original object from wrapped object (unwrapping partials, wrapped - functions, and other decorators). - """ - while True: - if stop and stop(obj): - return obj + if callable(stop): + while not stop(obj): + if ispartial(obj): + obj = obj.func + elif inspect.isroutine(obj) and hasattr(obj, '__wrapped__'): + obj = obj.__wrapped__ + elif isclassmethod(obj) or isstaticmethod(obj): + obj = obj.__func__ + else: + return obj + return obj # in case the while loop never starts + while True: if ispartial(obj): obj = obj.func elif inspect.isroutine(obj) and hasattr(obj, '__wrapped__'): @@ -72,10 +89,11 @@ def unwrap_all(obj: Any, *, stop: Callable[[Any], bool] | None = None) -> Any: def getall(obj: Any) -> Sequence[str] | None: - """Get __all__ attribute of the module as dict. + """Get the ``__all__`` attribute of an module as sequence. - Return None if given *obj* does not have __all__. - Raises ValueError if given *obj* have invalid __all__. + This returns ``None`` if the given ``obj.__all__`` does not exist and + raises :exc:`ValueError` if ``obj.__all__`` is not a list or tuple of + strings. """ __all__ = safe_getattr(obj, '__all__', None) if __all__ is None: @@ -86,7 +104,7 @@ def getall(obj: Any) -> Sequence[str] | None: def getannotations(obj: Any) -> Mapping[str, Any]: - """Get __annotations__ from given *obj* safely.""" + """Safely get the ``__annotations__`` attribute of an object.""" if sys.version_info >= (3, 10, 0) or not isinstance(obj, type): __annotations__ = safe_getattr(obj, '__annotations__', None) else: @@ -100,7 +118,7 @@ def getannotations(obj: Any) -> Mapping[str, Any]: def getglobals(obj: Any) -> Mapping[str, Any]: - """Get __globals__ from given *obj* safely.""" + """Safely get :attr:`obj.__globals__ `.""" __globals__ = safe_getattr(obj, '__globals__', None) if isinstance(__globals__, Mapping): return __globals__ @@ -108,7 +126,7 @@ def getglobals(obj: Any) -> Mapping[str, Any]: def getmro(obj: Any) -> tuple[type, ...]: - """Get __mro__ from given *obj* safely.""" + """Safely get :attr:`obj.__mro__ `.""" __mro__ = safe_getattr(obj, '__mro__', None) if isinstance(__mro__, tuple): return __mro__ @@ -116,8 +134,12 @@ def getmro(obj: Any) -> tuple[type, ...]: def getorigbases(obj: Any) -> tuple[Any, ...] | None: - """Get __orig_bases__ from *obj* safely.""" - if not inspect.isclass(obj): + """Safely get ``obj.__orig_bases__``. + + This returns ``None`` if the object is not a class or if ``__orig_bases__`` + is not well-defined (e.g., a non-tuple object or an empty sequence). + """ + if not isclass(obj): return None # Get __orig_bases__ from obj.__dict__ to avoid accessing the parent's __orig_bases__. @@ -129,14 +151,14 @@ def getorigbases(obj: Any) -> tuple[Any, ...] | None: return None -def getslots(obj: Any) -> dict[str, Any] | None: - """Get __slots__ attribute of the class as dict. +def getslots(obj: Any) -> dict[str, Any] | dict[str, None] | None: + """Safely get :term:`obj.__slots__ <__slots__>` as a dictionary if any. - Return None if gienv *obj* does not have __slots__. - Raises TypeError if given *obj* is not a class. - Raises ValueError if given *obj* have invalid __slots__. + - This returns ``None`` if ``obj.__slots__`` does not exist. + - This raises a :exc:`TypeError` if *obj* is not a class. + - This raises a :exc:`ValueError` if ``obj.__slots__`` is invalid. """ - if not inspect.isclass(obj): + if not isclass(obj): raise TypeError __slots__ = safe_getattr(obj, '__slots__', None) @@ -153,7 +175,7 @@ def getslots(obj: Any) -> dict[str, Any] | None: def isNewType(obj: Any) -> bool: - """Check the if object is a kind of NewType.""" + """Check the if object is a kind of :class:`~typing.NewType`.""" if sys.version_info[:2] >= (3, 10): return isinstance(obj, typing.NewType) __module__ = safe_getattr(obj, '__module__', None) @@ -162,19 +184,21 @@ def isNewType(obj: Any) -> bool: def isenumclass(x: Any) -> bool: - """Check if the object is subclass of enum.""" - return inspect.isclass(x) and issubclass(x, enum.Enum) + """Check if the object is an :class:`enumeration class `.""" + return isclass(x) and issubclass(x, enum.Enum) def isenumattribute(x: Any) -> bool: - """Check if the object is attribute of enum.""" + """Check if the object is an enumeration attribute.""" return isinstance(x, enum.Enum) def unpartial(obj: Any) -> Any: - """Get an original object from partial object. + """Get an original object from a partial-like object. + + If *obj* is not a partial object, it is returned as is. - This returns given object itself if not partial. + .. seealso:: :func:`ispartial` """ while ispartial(obj): obj = obj.func @@ -182,15 +206,15 @@ def unpartial(obj: Any) -> Any: def ispartial(obj: Any) -> bool: - """Check if the object is partial.""" + """Check if the object is a partial function or method.""" return isinstance(obj, (partial, partialmethod)) def isclassmethod(obj: Any, cls: Any = None, name: str | None = None) -> bool: - """Check if the object is classmethod.""" + """Check if the object is a :class:`classmethod`.""" if isinstance(obj, classmethod): return True - if inspect.ismethod(obj) and obj.__self__ is not None and isclass(obj.__self__): + if ismethod(obj) and obj.__self__ is not None and isclass(obj.__self__): return True if cls and name: # trace __mro__ if the method is defined in parent class @@ -203,7 +227,7 @@ def isclassmethod(obj: Any, cls: Any = None, name: str | None = None) -> bool: def isstaticmethod(obj: Any, cls: Any = None, name: str | None = None) -> bool: - """Check if the object is staticmethod.""" + """Check if the object is a :class:`staticmethod`.""" if isinstance(obj, staticmethod): return True if cls and name: @@ -217,14 +241,14 @@ def isstaticmethod(obj: Any, cls: Any = None, name: str | None = None) -> bool: def isdescriptor(x: Any) -> bool: - """Check if the object is some kind of descriptor.""" + """Check if the object is a :external+python:term:`descriptor`.""" return any( callable(safe_getattr(x, item, None)) for item in ('__get__', '__set__', '__delete__') ) def isabstractmethod(obj: Any) -> bool: - """Check if the object is an abstractmethod.""" + """Check if the object is an :func:`abstractmethod`.""" return safe_getattr(obj, '__isabstractmethod__', False) is True @@ -242,20 +266,20 @@ def is_cython_function_or_method(obj: Any) -> bool: def isattributedescriptor(obj: Any) -> bool: - """Check if the object is an attribute like descriptor.""" + """Check if the object is an attribute-like descriptor.""" if inspect.isdatadescriptor(obj): # data descriptor is kind of attribute return True if isdescriptor(obj): # non data descriptor unwrapped = unwrap(obj) - if isfunction(unwrapped) or isbuiltin(unwrapped) or inspect.ismethod(unwrapped): + if isfunction(unwrapped) or isbuiltin(unwrapped) or ismethod(unwrapped): # attribute must not be either function, builtin and method return False if is_cython_function_or_method(unwrapped): # attribute must not be either function and method (for cython) return False - if inspect.isclass(unwrapped): + if isclass(unwrapped): # attribute must not be a class return False if isinstance( @@ -269,7 +293,7 @@ def isattributedescriptor(obj: Any) -> bool: def is_singledispatch_function(obj: Any) -> bool: - """Check if the object is singledispatch function.""" + """Check if the object is a :func:`~functools.singledispatch` function.""" return ( inspect.isfunction(obj) and hasattr(obj, 'dispatch') @@ -279,27 +303,42 @@ def is_singledispatch_function(obj: Any) -> bool: def is_singledispatch_method(obj: Any) -> bool: - """Check if the object is singledispatch method.""" + """Check if the object is a :class:`~functools.singledispatchmethod`.""" return isinstance(obj, singledispatchmethod) def isfunction(obj: Any) -> bool: - """Check if the object is function.""" + """Check if the object is a user-defined function. + + Partial objects are unwrapped before checking them. + + .. seealso:: :external+python:func:`inspect.isfunction` + """ return inspect.isfunction(unpartial(obj)) def isbuiltin(obj: Any) -> bool: - """Check if the object is function.""" + """Check if the object is a built-in function or method. + + Partial objects are unwrapped before checking them. + + .. seealso:: :external+python:func:`inspect.isbuiltin` + """ return inspect.isbuiltin(unpartial(obj)) def isroutine(obj: Any) -> bool: - """Check is any kind of function or method.""" + """Check if the object is a kind of function or method. + + Partial objects are unwrapped before checking them. + + .. seealso:: :external+python:func:`inspect.isroutine` + """ return inspect.isroutine(unpartial(obj)) def iscoroutinefunction(obj: Any) -> bool: - """Check if the object is coroutine-function.""" + """Check if the object is a :external+python:term:`coroutine` function.""" def iswrappedcoroutine(obj: Any) -> bool: """Check if the object is wrapped coroutine-function.""" @@ -314,12 +353,12 @@ def iswrappedcoroutine(obj: Any) -> bool: def isproperty(obj: Any) -> bool: - """Check if the object is property.""" + """Check if the object is property (possibly cached).""" return isinstance(obj, (property, cached_property)) def isgenericalias(obj: Any) -> bool: - """Check if the object is GenericAlias.""" + """Check if the object is a generic alias.""" return isinstance(obj, (types.GenericAlias, typing._BaseGenericAlias)) # type: ignore[attr-defined] @@ -417,13 +456,14 @@ def object_description(obj: Any, *, _seen: frozenset[int] = frozenset()) -> str: def is_builtin_class_method(obj: Any, attr_name: str) -> bool: - """If attr_name is implemented at builtin class, return True. + """Check whether *attr_name* is implemented on a builtin class. >>> is_builtin_class_method(int, '__init__') True - Why this function needed? CPython implements int.__init__ by Descriptor - but PyPy implements it by pure Python code. + + This function is needed since CPython implements ``int.__init__`` via + descriptors, but PyPy implementation is written in pure Python code. """ mro = getmro(obj) @@ -454,9 +494,9 @@ def __repr__(self) -> str: class TypeAliasForwardRef: - """Pseudo typing class for autodoc_type_aliases. + """Pseudo typing class for :confval:`autodoc_type_aliases`. - This avoids the error on evaluating the type inside `get_type_hints()`. + This avoids the error on evaluating the type inside :func:`typing.get_type_hints()`. """ def __init__(self, name: str) -> None: @@ -477,9 +517,9 @@ def __repr__(self) -> str: class TypeAliasModule: - """Pseudo module class for autodoc_type_aliases.""" + """Pseudo module class for :confval:`autodoc_type_aliases`.""" - def __init__(self, modname: str, mapping: dict[str, str]) -> None: + def __init__(self, modname: str, mapping: Mapping[str, str]) -> None: self.__modname = modname self.__mapping = mapping @@ -510,9 +550,9 @@ def __getattr__(self, name: str) -> Any: class TypeAliasNamespace(dict[str, Any]): - """Pseudo namespace class for autodoc_type_aliases. + """Pseudo namespace class for :confval:`autodoc_type_aliases`. - This enables to look up nested modules and classes like `mod1.mod2.Class`. + Useful for looking up nested objects via ``namespace.foo.bar.Class``. """ def __init__(self, mapping: Mapping[str, str]) -> None: @@ -533,7 +573,7 @@ def __getitem__(self, key: str) -> Any: raise KeyError -def _should_unwrap(subject: Callable) -> bool: +def _should_unwrap(subject: Callable[..., Any]) -> bool: """Check the function should be unwrapped on getting signature.""" __globals__ = getglobals(subject) # contextmanger should be unwrapped @@ -544,10 +584,10 @@ def _should_unwrap(subject: Callable) -> bool: def signature( - subject: Callable, + subject: Callable[..., Any], bound_method: bool = False, type_aliases: Mapping[str, str] | None = None, -) -> inspect.Signature: +) -> Signature: """Return a Signature object for the given *subject*. :param bound_method: Specify *subject* is a bound method or not @@ -600,16 +640,16 @@ def signature( # # For example, this helps a function having a default value `inspect._empty`. # refs: https://github.com/sphinx-doc/sphinx/issues/7935 - return inspect.Signature( + return Signature( parameters, return_annotation=return_annotation, __validate_parameters__=False ) def evaluate_signature( - sig: inspect.Signature, + sig: Signature, globalns: dict[str, Any] | None = None, localns: dict[str, Any] | None = None, -) -> inspect.Signature: +) -> Signature: """Evaluate unresolved type annotations in a signature object.""" def evaluate_forwardref( @@ -662,12 +702,12 @@ def evaluate( def stringify_signature( - sig: inspect.Signature, + sig: Signature, show_annotation: bool = True, show_return_annotation: bool = True, unqualified_typehints: bool = False, ) -> str: - """Stringify a Signature object. + """Stringify a :class:`~inspect.Signature` object. :param show_annotation: If enabled, show annotations on the signature :param show_return_annotation: If enabled, show annotation of the return value @@ -724,12 +764,12 @@ def stringify_signature( if sig.return_annotation is EMPTY or not show_annotation or not show_return_annotation: return f'({concatenated_args})' else: - annotation = stringify_annotation(sig.return_annotation, mode) - return f'({concatenated_args}) -> {annotation}' + retann = stringify_annotation(sig.return_annotation, mode) + return f'({concatenated_args}) -> {retann}' -def signature_from_str(signature: str) -> inspect.Signature: - """Create a Signature object from string.""" +def signature_from_str(signature: str) -> Signature: + """Create a :class:`~inspect.Signature` object from a string.""" code = 'def func' + signature + ': pass' module = ast.parse(code) function = typing.cast(ast.FunctionDef, module.body[0]) @@ -737,14 +777,14 @@ def signature_from_str(signature: str) -> inspect.Signature: return signature_from_ast(function, code) -def signature_from_ast(node: ast.FunctionDef, code: str = '') -> inspect.Signature: - """Create a Signature object from AST *node*.""" - EMPTY: type[Any] = Parameter.empty +def signature_from_ast(node: ast.FunctionDef, code: str = '') -> Signature: + """Create a :class:`~inspect.Signature` object from an AST node.""" + EMPTY = Parameter.empty args: ast.arguments = node.args - default_expressions: tuple[ast.expr | None, ...] = tuple(args.defaults) + defaults: tuple[ast.expr | None, ...] = tuple(args.defaults) pos_only_offset = len(args.posonlyargs) - defaults_offset = pos_only_offset + len(args.args) - len(args.defaults) + defaults_offset = pos_only_offset + len(args.args) - len(defaults) # The sequence ``D = args.defaults`` contains non-None AST expressions, # so we can use ``None`` as a sentinel value for that to indicate that # there is no default value for a specific parameter. @@ -755,41 +795,38 @@ def signature_from_ast(node: ast.FunctionDef, code: str = '') -> inspect.Signatu # argument. Since a non-default argument cannot follow a default argument, # the sequence *D* can be completed on the left by adding None sentinels # so that ``len(D) == p`` and ``D[i]`` is the *i*-th default argument. - default_expressions = (None,) * defaults_offset + default_expressions + defaults = (None,) * defaults_offset + defaults # construct the parameters list params: list[Parameter] = [] - # The real type of a parameter's kind is ``inspect._ParameterKind`` - # but this (integral) enumeration is not part of the public API. - def process_arg(kind: Any, arg: ast.arg, *, defexpr: ast.expr | None = None) -> None: + def define(kind: _ParameterKind, arg: ast.arg, *, defexpr: ast.expr | None) -> None: default: Any = EMPTY if defexpr is None else DefaultValue(ast_unparse(defexpr, code)) annotation = ast_unparse(arg.annotation, code) or EMPTY params.append(Parameter(arg.arg, kind, default=default, annotation=annotation)) # positional-only arguments (introduced in Python 3.8) - for arg, defexpr in zip(args.posonlyargs, default_expressions): - process_arg(Parameter.POSITIONAL_ONLY, arg, defexpr=defexpr) + for arg, defexpr in zip(args.posonlyargs, defaults): + define(Parameter.POSITIONAL_ONLY, arg, defexpr=defexpr) # normal arguments - for arg, defexpr in zip(args.args, default_expressions[pos_only_offset:]): - process_arg(Parameter.POSITIONAL_OR_KEYWORD, arg, defexpr=defexpr) + for arg, defexpr in zip(args.args, defaults[pos_only_offset:]): + define(Parameter.POSITIONAL_OR_KEYWORD, arg, defexpr=defexpr) # variadic positional argument (no possible default expression) if args.vararg: - process_arg(Parameter.VAR_POSITIONAL, args.vararg, defexpr=None) + define(Parameter.VAR_POSITIONAL, args.vararg, defexpr=None) # keyword-only arguments for arg, defexpr in zip(args.kwonlyargs, args.kw_defaults): - process_arg(Parameter.KEYWORD_ONLY, arg, defexpr=defexpr) + define(Parameter.KEYWORD_ONLY, arg, defexpr=defexpr) # variadic keyword argument (no possible default expression) if args.kwarg: - process_arg(Parameter.VAR_KEYWORD, args.kwarg, defexpr=None) + define(Parameter.VAR_KEYWORD, args.kwarg, defexpr=None) return_annotation = ast_unparse(node.returns, code) or EMPTY - - return inspect.Signature(params, return_annotation=return_annotation) + return Signature(params, return_annotation=return_annotation) def getdoc( From 6eb072aaa078bc3dd66a55f908654d7b06068553 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Wed, 10 Apr 2024 10:06:08 +0200 Subject: [PATCH 06/11] fixup --- sphinx/ext/autodoc/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/sphinx/ext/autodoc/__init__.py b/sphinx/ext/autodoc/__init__.py index b5cae374b0e..c3b465705fd 100644 --- a/sphinx/ext/autodoc/__init__.py +++ b/sphinx/ext/autodoc/__init__.py @@ -2437,9 +2437,9 @@ def should_suppress_value_header(self) -> bool: def get_doc(self) -> list[list[str]] | None: if self.object is SLOTSATTR: try: - parent___slots__ = inspect.getslots(self.parent) - if parent___slots__ and parent___slots__.get(self.objpath[-1]): - docstring = prepare_docstring(parent___slots__[self.objpath[-1]]) + parent_slots = inspect.getslots(self.parent) + if parent_slots and (docstring := parent_slots.get(self.objpath[-1])): + docstring = prepare_docstring(docstring) return [docstring] else: return [] From 392a372510342bd5e2dbfd2fd4e1804e72cbc32a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Wed, 10 Apr 2024 10:22:19 +0200 Subject: [PATCH 07/11] fixup --- sphinx/util/inspect.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sphinx/util/inspect.py b/sphinx/util/inspect.py index efde17b06e8..c04cd84faea 100644 --- a/sphinx/util/inspect.py +++ b/sphinx/util/inspect.py @@ -89,7 +89,7 @@ class methods and static methods. def getall(obj: Any) -> Sequence[str] | None: - """Get the ``__all__`` attribute of an module as sequence. + """Get the ``__all__`` attribute of an object as sequence. This returns ``None`` if the given ``obj.__all__`` does not exist and raises :exc:`ValueError` if ``obj.__all__`` is not a list or tuple of From 00aaf05ac64d09d505804d008dcc3a3ae6b09880 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Sat, 13 Apr 2024 10:58:31 +0200 Subject: [PATCH 08/11] cleanup --- sphinx/util/inspect.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sphinx/util/inspect.py b/sphinx/util/inspect.py index c72d150a005..94055e469cf 100644 --- a/sphinx/util/inspect.py +++ b/sphinx/util/inspect.py @@ -381,7 +381,7 @@ def _is_wrapped_coroutine(obj: Any) -> bool: return hasattr(obj, '__wrapped__') -def isproperty(obj: Any) -> bool: +def isproperty(obj: Any) -> TypeGuard[property | cached_property]: """Check if the object is property (possibly cached).""" return isinstance(obj, (property, cached_property)) From 38d85edf5010f4dec4004ed701aecacd45c2a318 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Sat, 13 Apr 2024 10:59:53 +0200 Subject: [PATCH 09/11] fixup --- sphinx/util/inspect.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sphinx/util/inspect.py b/sphinx/util/inspect.py index 94055e469cf..3d833369288 100644 --- a/sphinx/util/inspect.py +++ b/sphinx/util/inspect.py @@ -211,7 +211,7 @@ def isNewType(obj: Any) -> bool: def isenumclass(x: Any) -> TypeGuard[type[Enum]]: """Check if the object is an :class:`enumeration class `.""" - return inspect.isclass(x) and issubclass(x, enum.Enum) + return isclass(x) and issubclass(x, enum.Enum) def isenumattribute(x: Any) -> TypeGuard[Enum]: From 9ed3b6a4876e4c858a5f47fd0fc516b4c1c66adc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Sat, 13 Apr 2024 11:10:00 +0200 Subject: [PATCH 10/11] explicit typing-extensions dependency --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 027504bc37f..8c7393fbf20 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -87,6 +87,7 @@ lint = [ "sphinx-lint", "types-docutils", "types-requests", + "typing-extensions", # implicitly required by mypy "importlib_metadata", # for mypy (Python<=3.9) "tomli", # for mypy (Python<=3.10) "pytest>=6.0", From fdb8e1b026061f732593b8e2fdb9e56e4a5e9263 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Sat, 13 Apr 2024 11:59:23 +0200 Subject: [PATCH 11/11] doc --- sphinx/util/typing.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/sphinx/util/typing.py b/sphinx/util/typing.py index 821aaa6cfe2..679a048c4a2 100644 --- a/sphinx/util/typing.py +++ b/sphinx/util/typing.py @@ -30,7 +30,7 @@ from sphinx.application import Sphinx - class _SpecialForm(typing.Protocol): + class _SpecialFormInterface(typing.Protocol): _name: str @@ -167,12 +167,19 @@ def is_system_TypeVar(typ: Any) -> bool: return modname == 'typing' and isinstance(typ, TypeVar) -def _is_special_form(obj: Any) -> TypeGuard[_SpecialForm]: +def _is_special_form(obj: Any) -> TypeGuard[_SpecialFormInterface]: + """Check if *obj* is a typing special form. + + The guarded type is a protocol with the members that Sphinx needs in + this module and not the native ``typing._SpecialForm`` from typeshed, + but the runtime type of *obj* must be a true special form instance. + """ return isinstance(obj, typing._SpecialForm) def _is_annotated_form(obj: Any) -> TypeGuard[Annotated[Any, ...]]: - return typing.get_origin(obj) is Annotated or str(obj) == 'typing.Annotated' + """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: