Skip to content

Commit

Permalink
Use PEP 604 display for typing.Optional and typing.Union (#11072
Browse files Browse the repository at this point in the history
)
  • Loading branch information
AA-Turner authored Jan 2, 2023
1 parent dc3f22a commit 77a02cf
Show file tree
Hide file tree
Showing 12 changed files with 305 additions and 251 deletions.
5 changes: 5 additions & 0 deletions doc/extdev/deprecated.rst
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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 @@ -2616,10 +2617,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 @@ -2744,9 +2746,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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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',
})
Loading

0 comments on commit 77a02cf

Please sign in to comment.