Source code for sphinx.util.typing

"""The composite types for Sphinx."""

from __future__ import annotations

import dataclasses
import sys
import types
import typing
from collections.abc import Callable, Sequence
from typing import TYPE_CHECKING

from docutils import nodes
from docutils.parsers.rst.states import Inliner

from sphinx.util import logging

if TYPE_CHECKING:
    from collections.abc import Mapping
    from typing import Annotated, Any, Final, Literal, Protocol, TypeAlias

    from typing_extensions import TypeIs

    from sphinx.application import Sphinx
    from sphinx.util.inventory import _InventoryItem

    _RestifyMode: TypeAlias = Literal[
        'fully-qualified-except-typing',
        'smart',
    ]
    _StringifyMode: TypeAlias = Literal[
        'fully-qualified-except-typing',
        'fully-qualified',
        'smart',
    ]

logger = logging.getLogger(__name__)


# classes that have an incorrect .__module__ attribute
# Map of (__module__, __qualname__) to the correct fully-qualified name
_INVALID_BUILTIN_CLASSES: Final[Mapping[tuple[str, str], str]] = {
    # types from 'contextvars'
    ('_contextvars', 'Context'): 'contextvars.Context',
    ('_contextvars', 'ContextVar'): 'contextvars.ContextVar',
    ('_contextvars', 'Token'): 'contextvars.Token',
    # types from 'ctypes':
    ('_ctypes', 'Array'): 'ctypes.Array',
    ('_ctypes', 'Structure'): 'ctypes.Structure',
    ('_ctypes', 'Union'): 'ctypes.Union',
    # types from 'io':
    ('_io', 'BufferedRandom'): 'io.BufferedRandom',
    ('_io', 'BufferedReader'): 'io.BufferedReader',
    ('_io', 'BufferedRWPair'): 'io.BufferedRWPair',
    ('_io', 'BufferedWriter'): 'io.BufferedWriter',
    ('_io', 'BytesIO'): 'io.BytesIO',
    ('_io', 'FileIO'): 'io.FileIO',
    ('_io', 'StringIO'): 'io.StringIO',
    ('_io', 'TextIOWrapper'): 'io.TextIOWrapper',
    # types from 'json':
    ('json.decoder', 'JSONDecoder'): 'json.JSONDecoder',
    ('json.encoder', 'JSONEncoder'): 'json.JSONEncoder',
    # types from 'lzma':
    ('_lzma', 'LZMACompressor'): 'lzma.LZMACompressor',
    ('_lzma', 'LZMADecompressor'): 'lzma.LZMADecompressor',
    # types from 'multiprocessing':
    ('multiprocessing.context', 'Process'): 'multiprocessing.Process',
    # types from 'pathlib':
    ('pathlib._local', 'Path'): 'pathlib.Path',
    ('pathlib._local', 'PosixPath'): 'pathlib.PosixPath',
    ('pathlib._local', 'PurePath'): 'pathlib.PurePath',
    ('pathlib._local', 'PurePosixPath'): 'pathlib.PurePosixPath',
    ('pathlib._local', 'PureWindowsPath'): 'pathlib.PureWindowsPath',
    ('pathlib._local', 'WindowsPath'): 'pathlib.WindowsPath',
    # types from 'pickle':
    ('_pickle', 'Pickler'): 'pickle.Pickler',
    ('_pickle', 'Unpickler'): 'pickle.Unpickler',
    # types from 'struct':
    ('_struct', 'Struct'): 'struct.Struct',
    # types from 'types':
    ('builtins', 'async_generator'): 'types.AsyncGeneratorType',
    ('builtins', 'builtin_function_or_method'): 'types.BuiltinMethodType',
    ('builtins', 'cell'): 'types.CellType',
    ('builtins', 'classmethod_descriptor'): 'types.ClassMethodDescriptorType',
    ('builtins', 'code'): 'types.CodeType',
    ('builtins', 'coroutine'): 'types.CoroutineType',
    ('builtins', 'ellipsis'): 'types.EllipsisType',
    ('builtins', 'frame'): 'types.FrameType',
    ('builtins', 'function'): 'types.LambdaType',
    ('builtins', 'generator'): 'types.GeneratorType',
    ('builtins', 'getset_descriptor'): 'types.GetSetDescriptorType',
    ('builtins', 'mappingproxy'): 'types.MappingProxyType',
    ('builtins', 'member_descriptor'): 'types.MemberDescriptorType',
    ('builtins', 'method'): 'types.MethodType',
    ('builtins', 'method-wrapper'): 'types.MethodWrapperType',
    ('builtins', 'method_descriptor'): 'types.MethodDescriptorType',
    ('builtins', 'module'): 'types.ModuleType',
    ('builtins', 'NoneType'): 'types.NoneType',
    ('builtins', 'NotImplementedType'): 'types.NotImplementedType',
    ('builtins', 'traceback'): 'types.TracebackType',
    ('builtins', 'wrapper_descriptor'): 'types.WrapperDescriptorType',
    # types from 'weakref':
    ('_weakrefset', 'WeakSet'): 'weakref.WeakSet',
    # types from 'zipfile':
    ('zipfile._path', 'CompleteDirs'): 'zipfile.CompleteDirs',
    ('zipfile._path', 'Path'): 'zipfile.Path',
}


def is_invalid_builtin_class(obj: Any) -> str:
    """Check *obj* is an invalid built-in class."""
    try:
        key = obj.__module__, obj.__qualname__
    except AttributeError:  # non-standard type
        return ''
    return _INVALID_BUILTIN_CLASSES.get(key, '')


# Text like nodes which are initialized with text and rawsource
TextlikeNode: TypeAlias = nodes.Text | nodes.TextElement

# path matcher
PathMatcher: TypeAlias = Callable[[str], bool]

# common role functions
if TYPE_CHECKING:

    class RoleFunction(Protocol):
        def __call__(
            self,
            name: str,
            rawtext: str,
            text: str,
            lineno: int,
            inliner: Inliner,
            /,
            options: dict[str, Any] | None = None,
            content: Sequence[str] = (),
        ) -> tuple[list[nodes.Node], list[nodes.system_message]]: ...

else:
    RoleFunction: TypeAlias = Callable[
        [str, str, str, int, Inliner, dict[str, typing.Any], Sequence[str]],
        tuple[list[nodes.Node], list[nodes.system_message]],
    ]

# A option spec for directive
OptionSpec: TypeAlias = dict[str, Callable[[str], typing.Any]]

# title getter functions for enumerable nodes (see sphinx.domains.std)
TitleGetter: TypeAlias = Callable[[nodes.Node], str]

# inventory data on memory
Inventory: TypeAlias = dict[str, dict[str, '_InventoryItem']]


[docs] class ExtensionMetadata(typing.TypedDict, total=False): """The metadata returned by an extension's ``setup()`` function. See :ref:`ext-metadata`. """ version: str """The extension version (default: ``'unknown version'``).""" env_version: int """An integer that identifies the version of env data added by the extension.""" parallel_read_safe: bool """Indicate whether parallel reading of source files is supported by the extension. """ parallel_write_safe: bool """Indicate whether parallel writing of output files is supported by the extension (default: ``True``). """
if TYPE_CHECKING: _ExtensionSetupFunc: TypeAlias = Callable[[Sphinx], ExtensionMetadata] # NoQA: PYI047 (false positive) def get_type_hints( obj: Any, globalns: dict[str, Any] | None = None, localns: dict[str, Any] | None = None, include_extras: bool = False, ) -> dict[str, Any]: """Return a dictionary containing type hints for a function, method, module or class object. This is a simple wrapper of `typing.get_type_hints()` that does not raise an error on runtime. """ from sphinx.util.inspect import safe_getattr # lazy loading try: return typing.get_type_hints( obj, globalns, localns, include_extras=include_extras ) except NameError: # Failed to evaluate ForwardRef (maybe TYPE_CHECKING) return safe_getattr(obj, '__annotations__', {}) except AttributeError: # Failed to evaluate ForwardRef (maybe not runtime checkable) return safe_getattr(obj, '__annotations__', {}) except TypeError: # Invalid object is given. But try to get __annotations__ as a fallback. return safe_getattr(obj, '__annotations__', {}) except KeyError: # a broken class found # See: https://github.com/sphinx-doc/sphinx/issues/8084 return {} def is_system_TypeVar(typ: Any) -> bool: """Check *typ* is system defined TypeVar.""" modname = getattr(typ, '__module__', '') return modname == 'typing' and isinstance(typ, typing.TypeVar) def _is_annotated_form(obj: Any) -> TypeIs[Annotated[Any, ...]]: """Check if *obj* is an annotated type.""" return ( typing.get_origin(obj) is typing.Annotated or str(obj).startswith('typing.Annotated') ) # fmt: skip def _is_unpack_form(obj: Any) -> bool: """Check if the object is :class:`typing.Unpack` or equivalent.""" return typing.get_origin(obj) is typing.Unpack def restify(cls: Any, mode: _RestifyMode = 'fully-qualified-except-typing') -> str: """Convert a type-like object to a reST reference. :param mode: Specify a method how annotations will be stringified. 'fully-qualified-except-typing' Show the module name and qualified name of the annotation except the "typing" module. 'smart' Show the name of the annotation. """ from sphinx.ext.autodoc.mock import ismock, ismockmodule # lazy loading from sphinx.util.inspect import isgenericalias, object_description # lazy loading valid_modes = {'fully-qualified-except-typing', 'smart'} if mode not in valid_modes: valid = ', '.join(map(repr, sorted(valid_modes))) msg = f'mode must be one of {valid}; got {mode!r}' raise ValueError(msg) # things that are not types if cls is None or cls == types.NoneType: return ':py:obj:`None`' if cls is Ellipsis: return '...' if isinstance(cls, str): return cls cls_module_is_typing = getattr(cls, '__module__', '') == 'typing' # If the mode is 'smart', we always use '~'. # If the mode is 'fully-qualified-except-typing', # we use '~' only for the objects in the ``typing`` module. module_prefix = '~' if mode == 'smart' or cls_module_is_typing else '' try: if ismockmodule(cls): return f':py:class:`{module_prefix}{cls.__name__}`' elif ismock(cls): return f':py:class:`{module_prefix}{cls.__module__}.{cls.__name__}`' elif fixed_cls := is_invalid_builtin_class(cls): # The above predicate never raises TypeError but should not be # evaluated before determining whether *cls* is a mocked object # or not; instead of two try-except blocks, we keep it here. return f':py:class:`{module_prefix}{fixed_cls}`' elif _is_annotated_form(cls): args = restify(cls.__args__[0], mode) meta_args = [] for m in cls.__metadata__: if isinstance(m, type): meta_args.append(restify(m, mode)) elif dataclasses.is_dataclass(m): # use restify for the repr of field values rather than repr d_fields = ', '.join([ rf'{f.name}=\ {restify(getattr(m, f.name), mode)}' for f in dataclasses.fields(m) if f.repr ]) meta_args.append(rf'{restify(type(m), mode)}\ ({d_fields})') else: meta_args.append(repr(m)) meta = ', '.join(meta_args) if sys.version_info[:2] <= (3, 11): # Hardcoded to fix errors on Python 3.11 and earlier. return rf':py:class:`~typing.Annotated`\ [{args}, {meta}]' return ( f':py:class:`{module_prefix}{cls.__module__}.{cls.__name__}`' rf'\ [{args}, {meta}]' ) elif isinstance(cls, typing.NewType): return f':py:class:`{module_prefix}{cls.__module__}.{cls.__name__}`' # type: ignore[attr-defined] elif isinstance(cls, types.UnionType) or ( isgenericalias(cls) and cls_module_is_typing and cls.__origin__ is typing.Union ): # Union types (PEP 585) retain their definition order when they # are printed natively and ``None``-like types are kept as is. # *cls* is defined in ``typing``, and thus ``__args__`` must exist return ' | '.join(restify(a, mode) for a in cls.__args__) elif cls.__module__ in {'__builtin__', 'builtins'}: if hasattr(cls, '__args__'): if not cls.__args__: # Empty tuple, list, ... return rf':py:class:`{cls.__name__}`\ [{cls.__args__!r}]' concatenated_args = ', '.join( restify(arg, mode) for arg in cls.__args__ ) return rf':py:class:`{cls.__name__}`\ [{concatenated_args}]' return f':py:class:`{cls.__name__}`' elif isgenericalias(cls): if cls.__name__ and not isinstance(cls.__origin__, typing._SpecialForm): # Represent generic aliases as the classes in ``typing`` rather # than the underlying aliased classes, # e.g. ``~typing.Tuple`` instead of ``tuple``. text = f':py:class:`{module_prefix}{cls.__module__}.{cls.__name__}`' else: text = restify(cls.__origin__, mode) __args__ = getattr(cls, '__args__', ()) if not __args__: return text if all(map(is_system_TypeVar, __args__)): # Don't print the arguments; they're all system defined type variables. return text # Callable has special formatting if ( (cls_module_is_typing and cls.__name__ == 'Callable') or (cls.__module__ == 'collections.abc' and cls.__name__ == 'Callable') ): # fmt: skip args = ', '.join(restify(a, mode) for a in __args__[:-1]) returns = restify(__args__[-1], mode) return rf'{text}\ [[{args}], {returns}]' if cls_module_is_typing and cls.__origin__.__name__ == 'Literal': args = ', '.join( _format_literal_arg_restify(a, mode=mode) for a in cls.__args__ ) return rf'{text}\ [{args}]' # generic representation of the parameters args = ', '.join(restify(a, mode) for a in __args__) return rf'{text}\ [{args}]' elif isinstance(cls, typing._SpecialForm): return f':py:obj:`~{cls.__module__}.{cls.__name__}`' # type: ignore[attr-defined] elif cls is typing.Any: # handle bpo-46998 return f':py:obj:`~{cls.__module__}.{cls.__name__}`' elif hasattr(cls, '__qualname__'): return f':py:class:`{module_prefix}{cls.__module__}.{cls.__qualname__}`' elif isinstance(cls, typing.ForwardRef): return f':py:class:`{cls.__forward_arg__}`' else: # not a class (ex. TypeVar) but should have a __name__ return f':py:obj:`{module_prefix}{cls.__module__}.{cls.__name__}`' except (AttributeError, TypeError) as exc: logger.debug('restify on %r in mode %r failed: %r', cls, mode, exc) return object_description(cls) def _format_literal_arg_restify(arg: Any, /, *, mode: str) -> str: from sphinx.util.inspect import isenumattribute # lazy loading if isenumattribute(arg): enum_cls = arg.__class__ if mode == 'smart' or enum_cls.__module__ == 'typing': # MyEnum.member return ( f':py:attr:`~{enum_cls.__module__}.{enum_cls.__qualname__}.{arg.name}`' ) # module.MyEnum.member return f':py:attr:`{enum_cls.__module__}.{enum_cls.__qualname__}.{arg.name}`' return repr(arg) def stringify_annotation( annotation: Any, /, mode: _StringifyMode = 'fully-qualified-except-typing', *, short_literals: bool = False, ) -> str: """Stringify type annotation object. :param annotation: The annotation to stringified. :param mode: Specify a method how annotations will be stringified. 'fully-qualified-except-typing' Show the module name and qualified name of the annotation except the "typing" module. 'smart' Show the name of the annotation. 'fully-qualified' Show the module name and qualified name of the annotation. :param short_literals: Render :py:class:`Literals` in PEP 604 style (``|``). """ from sphinx.ext.autodoc.mock import ismock, ismockmodule # lazy loading valid_modes = {'fully-qualified-except-typing', 'fully-qualified', 'smart'} if mode not in valid_modes: valid = ', '.join(map(repr, sorted(valid_modes))) msg = f'mode must be one of {valid}; got {mode!r}' raise ValueError(msg) # things that are not types if annotation is None or annotation == types.NoneType: return 'None' if annotation is Ellipsis: return '...' 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 if not annotation: return repr(annotation) module_prefix = '~' if mode == 'smart' else '' # The values below must be strings if the objects are well-formed. annotation_qualname: str = getattr(annotation, '__qualname__', '') annotation_module: str = getattr(annotation, '__module__', '') annotation_name: str = getattr(annotation, '__name__', '') annotation_module_is_typing = annotation_module == 'typing' if sys.version_info[:2] >= (3, 14) and isinstance(annotation, typing.ForwardRef): # ForwardRef moved from `typing` to `annotationlib` in Python 3.14. annotation_module_is_typing = True # Extract the annotation's base type by considering formattable cases if isinstance(annotation, typing.TypeVar) and not _is_unpack_form(annotation): # typing_extensions.Unpack is incorrectly determined as a TypeVar if annotation_module_is_typing and mode in { 'fully-qualified-except-typing', 'smart', }: return annotation_name return module_prefix + f'{annotation_module}.{annotation_name}' elif isinstance(annotation, typing.NewType): return module_prefix + f'{annotation_module}.{annotation_name}' elif ismockmodule(annotation): return module_prefix + annotation_name elif ismock(annotation): return module_prefix + f'{annotation_module}.{annotation_name}' elif fixed_annotation := is_invalid_builtin_class(annotation): return module_prefix + fixed_annotation elif _is_annotated_form(annotation): # for py310+ pass elif annotation_module == 'builtins' and annotation_qualname: args = getattr(annotation, '__args__', None) if args is None: return annotation_qualname # PEP 585 generic if not args: # Empty tuple, list, ... return repr(annotation) concatenated_args = ', '.join( stringify_annotation(arg, mode=mode, short_literals=short_literals) for arg in args ) return f'{annotation_qualname}[{concatenated_args}]' else: # add other special cases that can be directly formatted pass module_prefix = f'{annotation_module}.' annotation_forward_arg: str | None = getattr(annotation, '__forward_arg__', None) if annotation_qualname or ( annotation_module_is_typing and not annotation_forward_arg ): if mode == 'smart': module_prefix = f'~{module_prefix}' if annotation_module_is_typing and mode == 'fully-qualified-except-typing': module_prefix = '' elif _is_unpack_form(annotation) and annotation_module == 'typing_extensions': module_prefix = '~' if mode == 'smart' else '' else: module_prefix = '' if annotation_module_is_typing: if annotation_forward_arg: # handle ForwardRefs qualname = annotation_forward_arg else: if annotation_name: qualname = annotation_name elif annotation_qualname: qualname = annotation_qualname else: # in this case, we know that the annotation is a member # of ``typing`` and all of them define ``__origin__`` qualname = stringify_annotation( annotation.__origin__, mode='fully-qualified-except-typing', short_literals=short_literals, ).replace('typing.', '') # ex. Union elif annotation_qualname: qualname = annotation_qualname elif hasattr(annotation, '__origin__'): # instantiated generic provided by a user qualname = stringify_annotation( annotation.__origin__, mode=mode, short_literals=short_literals ) elif isinstance(annotation, types.UnionType): qualname = 'types.UnionType' else: # we weren't able to extract the base type, appending arguments would # only make them appear twice return repr(annotation) # Process the generic arguments (if any). # They must be a list or a tuple, otherwise they are considered 'broken'. annotation_args = getattr(annotation, '__args__', ()) if annotation_args and isinstance(annotation_args, list | tuple): if ( qualname in {'Union', 'types.UnionType'} and all(getattr(a, '__origin__', ...) is typing.Literal for a in annotation_args) ): # fmt: skip # special case to flatten a Union of Literals into a literal flattened_args = typing.Literal[annotation_args].__args__ # type: ignore[attr-defined] args = ', '.join( _format_literal_arg_stringify(a, mode=mode) for a in flattened_args ) return f'{module_prefix}Literal[{args}]' if qualname in {'Optional', 'Union', 'types.UnionType'}: return ' | '.join( stringify_annotation(a, mode=mode, short_literals=short_literals) for a in annotation_args ) elif qualname == 'Callable': args = ', '.join( stringify_annotation(a, mode=mode, short_literals=short_literals) for a in annotation_args[:-1] ) returns = stringify_annotation( annotation_args[-1], mode=mode, short_literals=short_literals ) return f'{module_prefix}Callable[[{args}], {returns}]' elif qualname == 'Literal': if short_literals: return ' | '.join( _format_literal_arg_stringify(a, mode=mode) for a in annotation_args ) args = ', '.join( _format_literal_arg_stringify(a, mode=mode) for a in annotation_args ) return f'{module_prefix}Literal[{args}]' elif _is_annotated_form(annotation): # for py310+ args = stringify_annotation( annotation_args[0], mode=mode, short_literals=short_literals ) meta_args = [] for m in annotation.__metadata__: if isinstance(m, type): meta_args.append( stringify_annotation( m, mode=mode, short_literals=short_literals ) ) elif dataclasses.is_dataclass(m): # use stringify_annotation for the repr of field values rather than repr d_fields = ', '.join([ f'{f.name}={stringify_annotation(getattr(m, f.name), mode=mode, short_literals=short_literals)}' # NoQA: E501 for f in dataclasses.fields(m) if f.repr ]) meta_args.append( f'{stringify_annotation(type(m), mode=mode, short_literals=short_literals)}({d_fields})' # NoQA: E501 ) else: meta_args.append(repr(m)) meta = ', '.join(meta_args) if sys.version_info[:2] <= (3, 11): if mode == 'fully-qualified-except-typing': return f'Annotated[{args}, {meta}]' module_prefix = module_prefix.replace('builtins', 'typing') return f'{module_prefix}Annotated[{args}, {meta}]' return f'{module_prefix}Annotated[{args}, {meta}]' elif all(is_system_TypeVar(a) for a in annotation_args): # Suppress arguments if all system defined TypeVars (ex. Dict[KT, VT]) return module_prefix + qualname else: args = ', '.join( stringify_annotation(a, mode=mode, short_literals=short_literals) for a in annotation_args ) return f'{module_prefix}{qualname}[{args}]' return module_prefix + qualname def _format_literal_arg_stringify(arg: Any, /, *, mode: str) -> str: from sphinx.util.inspect import isenumattribute # lazy loading if isenumattribute(arg): enum_cls = arg.__class__ if mode == 'smart' or enum_cls.__module__ == 'typing': # MyEnum.member return f'{enum_cls.__qualname__}.{arg.name}' # module.MyEnum.member return f'{enum_cls.__module__}.{enum_cls.__qualname__}.{arg.name}' return repr(arg) # deprecated name -> (object to return, canonical path or empty string, removal version) _DEPRECATED_OBJECTS: dict[str, tuple[Any, str, tuple[int, int]]] = { } # fmt: skip def __getattr__(name: str) -> Any: if name not in _DEPRECATED_OBJECTS: msg = f'module {__name__!r} has no attribute {name!r}' raise AttributeError(msg) from sphinx.deprecation import _deprecation_warning deprecated_object, canonical_name, remove = _DEPRECATED_OBJECTS[name] _deprecation_warning(__name__, name, canonical_name, remove=remove) return deprecated_object