Skip to content

Commit

Permalink
Add an option for displaying short Literal types
Browse files Browse the repository at this point in the history
The new ``python_display_short_literal_types`` configuration option
for the ``py`` domain controls display of PEP 586 ``Literal`` types.
The 'short' format is inspired by PEP 604, using the bitwise OR operator
to distinguish the possible legal values for the argument.
  • Loading branch information
AA-Turner committed Apr 5, 2023
1 parent 609b2f2 commit a2de47c
Show file tree
Hide file tree
Showing 8 changed files with 156 additions and 15 deletions.
2 changes: 2 additions & 0 deletions CHANGES
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ Features added
Patch by Jeremy Maitin-Shepard
* #11281: Support for :confval:`imgmath_latex` ``= 'tectonic'`` or
``= 'xelatex'``. Patch by Dimitar Dimitrov
* #11109, #9643: Add :confval:`python_display_short_literal_types` option for
condensed rendering of ``Literal`` types.

Bugs fixed
----------
Expand Down
30 changes: 30 additions & 0 deletions doc/usage/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2936,6 +2936,36 @@ Options for the C++ domain
Options for the Python domain
-----------------------------

.. confval:: python_display_short_literal_types

This value controls how :py:data:`~typing.Literal` types are displayed.
The setting is a boolean, default ``False``.

Examples
~~~~~~~~

The examples below use the following :rst:dir:`py:function` directive:

.. code:: reStructuredText
.. py:function:: serve_food(item: Literal["egg", "spam", "lobster thermidor"]) -> None
When ``False``, :py:data:`~typing.Literal` types display as per standard
Python syntax, i.e.:

.. code:: python
serve_food(item: Literal["egg", "spam", "lobster thermidor"]) -> None
When ``True``, :py:data:`~typing.Literal` types display with a short,
:PEP:`604`-inspired syntax, i.e.:

.. code:: python
serve_food(item: "egg" | "spam" | "lobster thermidor") -> None
.. versionadded:: 6.2

.. confval:: python_use_unqualified_type_names

If true, suppress the module name of the python reference if it can be
Expand Down
1 change: 1 addition & 0 deletions sphinx/domains/python.py
Original file line number Diff line number Diff line change
Expand Up @@ -1511,6 +1511,7 @@ def setup(app: Sphinx) -> dict[str, Any]:

app.add_domain(PythonDomain)
app.add_config_value('python_use_unqualified_type_names', False, 'env')
app.add_config_value('python_display_short_literal_types', False, 'env')
app.connect('object-description-transform', filter_meta_fields)
app.connect('missing-reference', builtin_resolver, priority=900)

Expand Down
16 changes: 11 additions & 5 deletions sphinx/util/inspect.py
Original file line number Diff line number Diff line change
Expand Up @@ -626,15 +626,21 @@ def evaluate(annotation: Any, globalns: dict, localns: dict) -> Any:
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,
short_literal_types: bool = False,
) -> str:
"""Stringify a Signature object.
:param show_annotation: If enabled, show annotations on the signature
:param show_return_annotation: If enabled, show annotation of the return value
:param unqualified_typehints: If enabled, show annotations as unqualified
(ex. io.StringIO -> StringIO)
:param short_literal_types: If enabled, show Literal annotations in PEP 604
style (ex. ``Literal[1, 2]`` -> ``1 | 2``)
"""
if unqualified_typehints:
mode = 'smart'
Expand Down Expand Up @@ -663,7 +669,7 @@ def stringify_signature(sig: inspect.Signature, show_annotation: bool = True,

if show_annotation and param.annotation is not param.empty:
arg.write(': ')
arg.write(stringify_annotation(param.annotation, mode))
arg.write(stringify_annotation(param.annotation, mode, short_literal_types))
if param.default is not param.empty:
if show_annotation and param.annotation is not param.empty:
arg.write(' = ')
Expand All @@ -684,7 +690,7 @@ def stringify_signature(sig: inspect.Signature, show_annotation: bool = True,
show_return_annotation is False):
return f'({concatenated_args})'
else:
annotation = stringify_annotation(sig.return_annotation, mode)
annotation = stringify_annotation(sig.return_annotation, mode, short_literal_types)
return f'({concatenated_args}) -> {annotation}'


Expand Down
40 changes: 31 additions & 9 deletions sphinx/util/typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ def stringify_annotation(
annotation: Any,
/,
mode: str = 'fully-qualified-except-typing',
short_literal_types: bool = False,
) -> str:
"""Stringify type annotation object.
Expand All @@ -227,6 +228,8 @@ def stringify_annotation(
Show the name of the annotation.
'fully-qualified'
Show the module name and qualified name of the annotation.
:param short_literal_types: Use PEP 604 style display for ``typing.Literal``
types, e.g. 'Literal[1, 2]' -> '1 | 2'.
"""
from sphinx.ext.autodoc.mock import ismock, ismockmodule # lazy loading
from sphinx.util.inspect import isNewType # lazy loading
Expand Down Expand Up @@ -304,13 +307,15 @@ def stringify_annotation(
qualname = annotation_qualname
else:
qualname = stringify_annotation(
annotation.__origin__, 'fully-qualified-except-typing',
annotation.__origin__,
'fully-qualified-except-typing',
short_literal_types,
).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)
qualname = stringify_annotation(annotation.__origin__, mode, short_literal_types)
elif UnionType and isinstance(annotation, UnionType): # types.UnionType (for py3.10+)
qualname = 'types.UnionType'
else:
Expand All @@ -323,22 +328,39 @@ def stringify_annotation(
if not isinstance(annotation_args, (list, tuple)):
# broken __args__ found
pass
elif qualname in {'Optional', 'Union', 'types.UnionType'}:
return ' | '.join(stringify_annotation(a, mode) for a in annotation_args)
elif qualname == 'Optional':
return ' | '.join(
stringify_annotation(a, mode, short_literal_types) for a in annotation_args
)
elif qualname in {'Union', 'types.UnionType'}:
# special case to flatten a Union of Literals into a literal
if all(getattr(a, '__origin__', ...) is typing.Literal for a in annotation_args):
flattened = typing.Literal[annotation_args] # type: ignore[valid-type]
return stringify_annotation(flattened, mode, short_literal_types)
return ' | '.join(
stringify_annotation(a, mode, short_literal_types) for a in annotation_args
)
elif qualname == 'Callable':
args = ', '.join(stringify_annotation(a, mode) for a in annotation_args[:-1])
returns = stringify_annotation(annotation_args[-1], mode)
args = ', '.join(
stringify_annotation(a, mode, short_literal_types)
for a in annotation_args[:-1]
)
returns = stringify_annotation(annotation_args[-1], mode, short_literal_types)
return f'{module_prefix}Callable[[{args}], {returns}]'
elif qualname == 'Literal':
args = ', '.join(repr(a) for a in annotation_args)
if short_literal_types:
return ' | '.join(map(repr, annotation_args))
args = ', '.join(map(repr, annotation_args))
return f'{module_prefix}Literal[{args}]'
elif str(annotation).startswith('typing.Annotated'): # for py39+
return stringify_annotation(annotation_args[0], mode)
return stringify_annotation(annotation_args[0], mode, short_literal_types)
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) for a in annotation_args)
args = ', '.join(
stringify_annotation(a, mode, short_literal_types) for a in annotation_args
)
return f'{module_prefix}{qualname}[{args}]'

return module_prefix + qualname
Expand Down
67 changes: 67 additions & 0 deletions tests/test_domain_py.py
Original file line number Diff line number Diff line change
Expand Up @@ -1470,3 +1470,70 @@ def test_module_content_line_number(app):
source, line = docutils.utils.get_source_line(xrefs[0])
assert 'index.rst' in source
assert line == 3


@pytest.mark.sphinx(freshenv=True, confoverrides={'python_display_short_literal_types': True})
def test_short_literal_types(app):
text = """\
.. py:function:: literal_ints(x: Literal[1, 2, 3] = 1) -> None
.. py:function:: literal_union(x: Union[Literal["a"], Literal["b"], Literal["c"]]) -> None
"""
doctree = restructuredtext.parse(app, text)
assert_node(doctree, (
addnodes.index,
[desc, (
[desc_signature, (
[desc_name, 'literal_ints'],
[desc_parameterlist, (
[desc_parameter, (
[desc_sig_name, 'x'],
[desc_sig_punctuation, ':'],
desc_sig_space,
[desc_sig_name, (
[desc_sig_literal_number, '1'],
desc_sig_space,
[desc_sig_punctuation, '|'],
desc_sig_space,
[desc_sig_literal_number, '2'],
desc_sig_space,
[desc_sig_punctuation, '|'],
desc_sig_space,
[desc_sig_literal_number, '3'],
)],
desc_sig_space,
[desc_sig_operator, '='],
desc_sig_space,
[nodes.inline, '1'],
)],
)],
[desc_returns, pending_xref, 'None'],
)],
[desc_content, ()],
)],
addnodes.index,
[desc, (
[desc_signature, (
[desc_name, 'literal_union'],
[desc_parameterlist, (
[desc_parameter, (
[desc_sig_name, 'x'],
[desc_sig_punctuation, ':'],
desc_sig_space,
[desc_sig_name, (
[desc_sig_literal_string, "'a'"],
desc_sig_space,
[desc_sig_punctuation, '|'],
desc_sig_space,
[desc_sig_literal_string, "'b'"],
desc_sig_space,
[desc_sig_punctuation, '|'],
desc_sig_space,
[desc_sig_literal_string, "'c'"],
)],
)],
)],
[desc_returns, pending_xref, 'None'],
)],
[desc_content, ()],
)],
))
9 changes: 9 additions & 0 deletions tests/test_util_inspect.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ def test_signature_annotations():
f23,
f24,
f25,
f26,
)

# Class annotations
Expand Down Expand Up @@ -316,6 +317,14 @@ def test_signature_annotations():
sig = inspect.signature(f25)
assert stringify_signature(sig) == '(a, b, /)'

# short_literal_types is False
sig = inspect.signature(f26)
assert stringify_signature(sig, short_literal_types=False) == "(x: typing.Literal[1, 2, 3] = 1, y: typing.Literal['a', 'b'] = 'a') -> None"

# short_literal_types is True
sig = inspect.signature(f26)
assert stringify_signature(sig, short_literal_types=True) == "(x: 1 | 2 | 3 = 1, y: 'a' | 'b' = 'a') -> None"


def test_signature_from_str_basic():
signature = '(a, b, *args, c=0, d="blah", **kwargs)'
Expand Down
6 changes: 5 additions & 1 deletion tests/typing_test_data.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from inspect import Signature
from numbers import Integral
from typing import Any, Callable, Dict, List, Optional, Tuple, TypeVar, Union
from typing import Any, Callable, Dict, List, Literal, Optional, Tuple, TypeVar, Union


def f0(x: int, y: Integral) -> None:
Expand Down Expand Up @@ -121,6 +121,10 @@ def f25(a, b, /):
pass


def f26(x: Literal[1, 2, 3] = 1, y: Union[Literal["a"], Literal["b"]] = "a") -> None:
pass


class Node:
def __init__(self, parent: Optional['Node']) -> None:
pass
Expand Down

0 comments on commit a2de47c

Please sign in to comment.