Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use PEP 604 display for typing.Optional and typing.Union #11072

Merged
merged 3 commits into from Jan 2, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
5 changes: 5 additions & 0 deletions doc/extdev/deprecated.rst
Expand Up @@ -22,6 +22,11 @@ The following is a list of deprecated interfaces.
- Removed
- Alternatives

* - ``sphinx.util.typing.stringify``
- 6.1
- 8.0
- ``sphinx.util.typing.stringify_annotation``

* - HTML 4 support
- 5.2
- 7.0
Expand Down
24 changes: 24 additions & 0 deletions sphinx/domains/python.py
Expand Up @@ -176,6 +176,8 @@ def unparse(node: ast.AST) -> list[Node]:
elif isinstance(node, ast.Name):
return [nodes.Text(node.id)]
elif isinstance(node, ast.Subscript):
if getattr(node.value, 'id', '') in {'Optional', 'Union'}:
return _unparse_pep_604_annotation(node)
result = unparse(node.value)
result.append(addnodes.desc_sig_punctuation('', '['))
result.extend(unparse(node.slice))
Expand Down Expand Up @@ -206,6 +208,28 @@ def unparse(node: ast.AST) -> list[Node]:
else:
raise SyntaxError # unsupported syntax

def _unparse_pep_604_annotation(node: ast.Subscript) -> list[Node]:
subscript = node.slice
if isinstance(subscript, ast.Index):
# py38 only
subscript = subscript.value # type: ignore[assignment]

flattened: list[Node] = []
if isinstance(subscript, ast.Tuple):
flattened.extend(unparse(subscript.elts[0]))
for elt in subscript.elts[1:]:
flattened.extend(unparse(ast.BitOr()))
flattened.extend(unparse(elt))
else:
# e.g. a Union[] inside an Optional[]
flattened.extend(unparse(subscript))

if getattr(node.value, 'id', '') == 'Optional':
flattened.extend(unparse(ast.BitOr()))
flattened.append(nodes.Text('None'))

return flattened

try:
tree = ast.parse(annotation, type_comments=True)
result: list[Node] = []
Expand Down
27 changes: 15 additions & 12 deletions sphinx/ext/autodoc/__init__.py
Expand Up @@ -27,8 +27,7 @@
from sphinx.util.docstrings import prepare_docstring, separate_metadata
from sphinx.util.inspect import (evaluate_signature, getdoc, object_description, safe_getattr,
stringify_signature)
from sphinx.util.typing import OptionSpec, get_type_hints, restify
from sphinx.util.typing import stringify as stringify_typehint
from sphinx.util.typing import OptionSpec, get_type_hints, restify, stringify_annotation

if TYPE_CHECKING:
from sphinx.ext.autodoc.directive import DocumenterBridge
Expand Down Expand Up @@ -1902,9 +1901,10 @@ def update_content(self, more_content: StringList) -> None:
attrs = [repr(self.object.__name__)]
for constraint in self.object.__constraints__:
if self.config.autodoc_typehints_format == "short":
attrs.append(stringify_typehint(constraint, "smart"))
attrs.append(stringify_annotation(constraint, "smart"))
else:
attrs.append(stringify_typehint(constraint))
attrs.append(stringify_annotation(constraint,
"fully-qualified-except-typing"))
if self.object.__bound__:
if self.config.autodoc_typehints_format == "short":
bound = restify(self.object.__bound__, "smart")
Expand Down Expand Up @@ -2027,10 +2027,11 @@ def add_directive_header(self, sig: str) -> None:
self.config.autodoc_type_aliases)
if self.objpath[-1] in annotations:
if self.config.autodoc_typehints_format == "short":
objrepr = stringify_typehint(annotations.get(self.objpath[-1]),
"smart")
objrepr = stringify_annotation(annotations.get(self.objpath[-1]),
"smart")
else:
objrepr = stringify_typehint(annotations.get(self.objpath[-1]))
objrepr = stringify_annotation(annotations.get(self.objpath[-1]),
"fully-qualified-except-typing")
self.add_line(' :type: ' + objrepr, sourcename)

try:
Expand Down Expand Up @@ -2622,10 +2623,11 @@ def add_directive_header(self, sig: str) -> None:
self.config.autodoc_type_aliases)
if self.objpath[-1] in annotations:
if self.config.autodoc_typehints_format == "short":
objrepr = stringify_typehint(annotations.get(self.objpath[-1]),
"smart")
objrepr = stringify_annotation(annotations.get(self.objpath[-1]),
"smart")
else:
objrepr = stringify_typehint(annotations.get(self.objpath[-1]))
objrepr = stringify_annotation(annotations.get(self.objpath[-1]),
"fully-qualified-except-typing")
self.add_line(' :type: ' + objrepr, sourcename)

try:
Expand Down Expand Up @@ -2750,9 +2752,10 @@ def add_directive_header(self, sig: str) -> None:
type_aliases=self.config.autodoc_type_aliases)
if signature.return_annotation is not Parameter.empty:
if self.config.autodoc_typehints_format == "short":
objrepr = stringify_typehint(signature.return_annotation, "smart")
objrepr = stringify_annotation(signature.return_annotation, "smart")
else:
objrepr = stringify_typehint(signature.return_annotation)
objrepr = stringify_annotation(signature.return_annotation,
"fully-qualified-except-typing")
self.add_line(' :type: ' + objrepr, sourcename)
except TypeError as exc:
logger.warning(__("Failed to get a function signature for %s: %s"),
Expand Down
7 changes: 4 additions & 3 deletions sphinx/ext/autodoc/typehints.py
Expand Up @@ -12,7 +12,8 @@
import sphinx
from sphinx import addnodes
from sphinx.application import Sphinx
from sphinx.util import inspect, typing
from sphinx.util import inspect
from sphinx.util.typing import stringify_annotation


def record_typehints(app: Sphinx, objtype: str, name: str, obj: Any,
Expand All @@ -30,9 +31,9 @@ def record_typehints(app: Sphinx, objtype: str, name: str, obj: Any,
sig = inspect.signature(obj, type_aliases=app.config.autodoc_type_aliases)
for param in sig.parameters.values():
if param.annotation is not param.empty:
annotation[param.name] = typing.stringify(param.annotation, mode)
annotation[param.name] = stringify_annotation(param.annotation, mode)
if sig.return_annotation is not sig.empty:
annotation['return'] = typing.stringify(sig.return_annotation, mode)
annotation['return'] = stringify_annotation(sig.return_annotation, mode)
except (TypeError, ValueError):
pass

Expand Down
6 changes: 3 additions & 3 deletions sphinx/ext/napoleon/docstring.py
Expand Up @@ -12,8 +12,7 @@
from sphinx.config import Config as SphinxConfig
from sphinx.locale import _, __
from sphinx.util import logging
from sphinx.util.inspect import stringify_annotation
from sphinx.util.typing import get_type_hints
from sphinx.util.typing import get_type_hints, stringify_annotation

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -876,7 +875,8 @@ def _lookup_annotation(self, _name: str) -> str:
) or {})
self._annotations = get_type_hints(self._obj, None, localns)
if _name in self._annotations:
return stringify_annotation(self._annotations[_name])
return stringify_annotation(self._annotations[_name],
'fully-qualified-except-typing')
# No annotation found
return ""

Expand Down
3 changes: 1 addition & 2 deletions sphinx/util/inspect.py
Expand Up @@ -22,8 +22,7 @@

from sphinx.pycode.ast import unparse as ast_unparse
from sphinx.util import logging
from sphinx.util.typing import ForwardRef
from sphinx.util.typing import stringify as stringify_annotation
from sphinx.util.typing import ForwardRef, stringify_annotation

logger = logging.getLogger(__name__)

Expand Down
152 changes: 84 additions & 68 deletions sphinx/util/typing.py
Expand Up @@ -11,6 +11,8 @@
from docutils import nodes
from docutils.parsers.rst.states import Inliner

from sphinx.deprecation import RemovedInSphinx80Warning, deprecated_alias

try:
from types import UnionType # type: ignore # python 3.10 or above
except ImportError:
Expand Down Expand Up @@ -205,9 +207,14 @@ def restify(cls: type | None, mode: str = 'fully-qualified-except-typing') -> st
return inspect.object_description(cls)


def stringify(annotation: Any, mode: str = 'fully-qualified-except-typing') -> str:
def stringify_annotation(
annotation: Any,
/,
mode: str = 'fully-qualified-except-typing',
) -> 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'
Expand All @@ -219,12 +226,20 @@ def stringify(annotation: Any, mode: str = 'fully-qualified-except-typing') -> s
Show the module name and qualified name of the annotation.
"""
from sphinx.ext.autodoc.mock import ismock, ismockmodule # lazy loading
from sphinx.util import inspect # lazy loading
from sphinx.util.inspect import isNewType # lazy loading

if mode not in {'fully-qualified-except-typing', 'fully-qualified', 'smart'}:
raise ValueError("'mode' must be one of 'fully-qualified-except-typing', "
f"'fully-qualified', or 'smart'; got {mode!r}.")

if mode == 'smart':
modprefix = '~'
module_prefix = '~'
else:
modprefix = ''
module_prefix = ''

annotation_qualname = getattr(annotation, '__qualname__', '')
annotation_module = getattr(annotation, '__module__', '')
annotation_name = getattr(annotation, '__name__', '')

if isinstance(annotation, str):
if annotation.startswith("'") and annotation.endswith("'"):
Expand All @@ -233,104 +248,105 @@ def stringify(annotation: Any, mode: str = 'fully-qualified-except-typing') -> s
else:
return annotation
elif isinstance(annotation, TypeVar):
if (annotation.__module__ == 'typing' and
mode in ('fully-qualified-except-typing', 'smart')):
return annotation.__name__
if (annotation_module == 'typing'
and mode in {'fully-qualified-except-typing', 'smart'}):
return annotation_name
else:
return modprefix + '.'.join([annotation.__module__, annotation.__name__])
elif inspect.isNewType(annotation):
return module_prefix + f'{annotation_module}.{annotation_name}'
elif isNewType(annotation):
if sys.version_info[:2] >= (3, 10):
# newtypes have correct module info since Python 3.10+
return modprefix + f'{annotation.__module__}.{annotation.__name__}'
return module_prefix + f'{annotation_module}.{annotation_name}'
else:
return annotation.__name__
return annotation_name
elif not annotation:
return repr(annotation)
elif annotation is NoneType:
return 'None'
elif ismockmodule(annotation):
return modprefix + annotation.__name__
return module_prefix + annotation_name
elif ismock(annotation):
return modprefix + f'{annotation.__module__}.{annotation.__name__}'
return module_prefix + f'{annotation_module}.{annotation_name}'
elif is_invalid_builtin_class(annotation):
return modprefix + INVALID_BUILTIN_CLASSES[annotation]
return module_prefix + INVALID_BUILTIN_CLASSES[annotation]
elif str(annotation).startswith('typing.Annotated'): # for py310+
pass
elif (getattr(annotation, '__module__', None) == 'builtins' and
getattr(annotation, '__qualname__', None)):
elif annotation_module == 'builtins' and annotation_qualname:
if hasattr(annotation, '__args__'): # PEP 585 generic
return repr(annotation)
else:
return annotation.__qualname__
return annotation_qualname
elif annotation is Ellipsis:
return '...'

module = getattr(annotation, '__module__', None)
modprefix = ''
if module == 'typing' and getattr(annotation, '__forward_arg__', None):
qualname = annotation.__forward_arg__
elif module == 'typing':
if getattr(annotation, '_name', None):
qualname = annotation._name
elif getattr(annotation, '__qualname__', None):
qualname = annotation.__qualname__
else:
qualname = stringify(annotation.__origin__).replace('typing.', '') # ex. Union

module_prefix = ''
annotation_forward_arg = getattr(annotation, '__forward_arg__', None)
if (annotation_qualname
or (annotation_module == 'typing' and not annotation_forward_arg)):
if mode == 'smart':
modprefix = '~%s.' % module
module_prefix = f'~{annotation_module}.'
elif mode == 'fully-qualified':
modprefix = '%s.' % module
elif hasattr(annotation, '__qualname__'):
if mode == 'smart':
modprefix = '~%s.' % module
module_prefix = f'{annotation_module}.'
elif annotation_module != 'typing' and mode == 'fully-qualified-except-typing':
module_prefix = f'{annotation_module}.'

if annotation_module == 'typing':
if annotation_forward_arg:
# handle ForwardRefs
qualname = annotation_forward_arg
else:
modprefix = '%s.' % module
qualname = annotation.__qualname__
_name = getattr(annotation, '_name', '')
if _name:
qualname = _name
elif annotation_qualname:
qualname = annotation_qualname
else:
qualname = stringify_annotation(
annotation.__origin__, 'fully-qualified-except-typing'
).replace('typing.', '') # ex. Union
elif annotation_qualname:
qualname = annotation_qualname
elif hasattr(annotation, '__origin__'):
# instantiated generic provided by a user
qualname = stringify(annotation.__origin__, mode)
elif UnionType and isinstance(annotation, UnionType): # types.Union (for py3.10+)
qualname = 'types.Union'
qualname = stringify_annotation(annotation.__origin__, mode)
elif UnionType and isinstance(annotation, UnionType): # types.UnionType (for py3.10+)
qualname = 'types.UnionType'
else:
# we weren't able to extract the base type, appending arguments would
# only make them appear twice
return repr(annotation)

if getattr(annotation, '__args__', None):
if not isinstance(annotation.__args__, (list, tuple)):
annotation_args = getattr(annotation, '__args__', None)
if annotation_args:
if not isinstance(annotation_args, (list, tuple)):
# broken __args__ found
pass
elif qualname in ('Optional', 'Union'):
if len(annotation.__args__) > 1 and annotation.__args__[-1] is NoneType:
if len(annotation.__args__) > 2:
args = ', '.join(stringify(a, mode) for a in annotation.__args__[:-1])
return f'{modprefix}Optional[{modprefix}Union[{args}]]'
else:
return f'{modprefix}Optional[{stringify(annotation.__args__[0], mode)}]'
else:
args = ', '.join(stringify(a, mode) for a in annotation.__args__)
return f'{modprefix}Union[{args}]'
elif qualname == 'types.Union':
if len(annotation.__args__) > 1 and None in annotation.__args__:
args = ' | '.join(stringify(a) for a in annotation.__args__ if a)
return f'{modprefix}Optional[{args}]'
else:
return ' | '.join(stringify(a) for a in annotation.__args__)
elif qualname in {'Optional', 'Union', 'types.UnionType'}:
return ' | '.join(stringify_annotation(a, mode) for a in annotation_args)
elif qualname == 'Callable':
args = ', '.join(stringify(a, mode) for a in annotation.__args__[:-1])
returns = stringify(annotation.__args__[-1], mode)
return f'{modprefix}{qualname}[[{args}], {returns}]'
args = ', '.join(stringify_annotation(a, mode) for a in annotation_args[:-1])
returns = stringify_annotation(annotation_args[-1], mode)
return f'{module_prefix}Callable[[{args}], {returns}]'
elif qualname == 'Literal':
args = ', '.join(repr(a) for a in annotation.__args__)
return f'{modprefix}{qualname}[{args}]'
args = ', '.join(repr(a) for a in annotation_args)
return f'{module_prefix}Literal[{args}]'
elif str(annotation).startswith('typing.Annotated'): # for py39+
return stringify(annotation.__args__[0], mode)
elif all(is_system_TypeVar(a) for a in annotation.__args__):
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])
return modprefix + qualname
return module_prefix + qualname
else:
args = ', '.join(stringify(a, mode) for a in annotation.__args__)
return f'{modprefix}{qualname}[{args}]'
args = ', '.join(stringify_annotation(a, mode) for a in annotation_args)
return f'{module_prefix}{qualname}[{args}]'

return module_prefix + qualname


return modprefix + qualname
deprecated_alias(__name__,
{
'stringify': stringify_annotation,
},
RemovedInSphinx80Warning,
{
'stringify': 'sphinx.util.typing.stringify_annotation',
})