diff --git a/.ruff.toml b/.ruff.toml index 55ad44b1dad..c2a39b12ce3 100644 --- a/.ruff.toml +++ b/.ruff.toml @@ -546,7 +546,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", @@ -555,7 +554,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/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", diff --git a/sphinx/ext/autodoc/__init__.py b/sphinx/ext/autodoc/__init__.py index 45e4cad65cc..f017ac14703 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 c2ab0feb442..fbc3276ca10 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 Iterator, Sequence + from typing import Any + + from typing_extensions import TypeGuard logger = logging.getLogger(__name__) @@ -153,7 +156,7 @@ def mock(modnames: list[str]) -> Iterator[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 70938131fd6..3d833369288 100644 --- a/sphinx/util/inspect.py +++ b/sphinx/util/inspect.py @@ -25,9 +25,34 @@ if TYPE_CHECKING: from collections.abc import Callable, Sequence + from enum import Enum from inspect import _ParameterKind from types import MethodType, ModuleType - from typing import Final + from typing import Final, 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__) @@ -184,12 +209,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 an :class:`enumeration class `.""" return isclass(x) and issubclass(x, enum.Enum) -def isenumattribute(x: Any) -> bool: +def isenumattribute(x: Any) -> TypeGuard[Enum]: """Check if the object is an enumeration attribute.""" return isinstance(x, enum.Enum) @@ -206,7 +231,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 a partial function or method.""" return isinstance(obj, (partial, partialmethod)) @@ -239,7 +264,7 @@ 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 a :external+python:term:`descriptor`.""" return any( callable(safe_getattr(x, item, None)) for item in ('__get__', '__set__', '__delete__') @@ -306,12 +331,12 @@ def is_singledispatch_function(obj: Any) -> bool: ) -def is_singledispatch_method(obj: Any) -> bool: +def is_singledispatch_method(obj: Any) -> TypeGuard[singledispatchmethod]: """Check if the object is a :class:`~functools.singledispatchmethod`.""" return isinstance(obj, singledispatchmethod) -def isfunction(obj: Any) -> bool: +def isfunction(obj: Any) -> TypeGuard[types.FunctionType]: """Check if the object is a user-defined function. Partial objects are unwrapped before checking them. @@ -321,7 +346,7 @@ def isfunction(obj: Any) -> bool: return inspect.isfunction(unpartial(obj)) -def isbuiltin(obj: Any) -> bool: +def isbuiltin(obj: Any) -> TypeGuard[types.BuiltinFunctionType]: """Check if the object is a built-in function or method. Partial objects are unwrapped before checking them. @@ -331,7 +356,7 @@ def isbuiltin(obj: Any) -> bool: return inspect.isbuiltin(unpartial(obj)) -def isroutine(obj: Any) -> bool: +def isroutine(obj: Any) -> TypeGuard[_RoutineType]: """Check if the object is a kind of function or method. Partial objects are unwrapped before checking them. @@ -356,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)) diff --git a/sphinx/util/typing.py b/sphinx/util/typing.py index a31731e7bb3..679a048c4a2 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 _SpecialFormInterface(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,27 @@ 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[_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, ...]]: + """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: + 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 +200,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 +337,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 +367,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 +418,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 +440,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 +470,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 +486,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 619fc344224..be00b63628d 100644 --- a/tests/test_util/test_util_typing.py +++ b/tests/test_util/test_util_typing.py @@ -192,6 +192,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`\\ " @@ -300,6 +301,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`") @@ -487,8 +489,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"