Skip to content

Commit 7ad7b20

Browse files
authored
Added the simplify_optional_unions config option (#141)
1 parent 2fac99f commit 7ad7b20

File tree

3 files changed

+59
-11
lines changed

3 files changed

+59
-11
lines changed

README.rst

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,13 @@ The following configuration options are accepted:
6565
be able to add type info.
6666
* ``typehints_document_rtype`` (default: ``True``): If ``False``, never add an ``:rtype:`` directive.
6767
If ``True``, add the ``:rtype:`` directive if no existing ``:rtype:`` is found.
68-
68+
* ``simplify_optional_unions`` (default: ``True``): If ``True``, optional parameters of type "Union[...]"
69+
are simplified as being of type Union[..., None] in the resulting documention
70+
(e.g. Optional[Union[A, B]] -> Union[A, B, None]).
71+
If ``False``, the "Optional"-type is kept.
72+
Note: If ``False``, **any** Union containing ``None`` will be displayed as Optional!
73+
Note: If an optional parameter has only a single type (e.g Optional[A] or Union[A, None]),
74+
it will **always** be displayed as Optional!
6975

7076
How it works
7177
------------

sphinx_autodoc_typehints.py

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,9 @@ def get_annotation_args(annotation, module: str, class_name: str) -> Tuple:
9595
return getattr(annotation, '__args__', ())
9696

9797

98-
def format_annotation(annotation, fully_qualified: bool = False) -> str:
98+
def format_annotation(annotation,
99+
fully_qualified: bool = False,
100+
simplify_optional_unions: bool = True) -> str:
99101
# Special cases
100102
if annotation is None or annotation is type(None): # noqa: E721
101103
return ':py:obj:`None`'
@@ -130,18 +132,28 @@ def format_annotation(annotation, fully_qualified: bool = False) -> str:
130132
if full_name == 'typing.NewType':
131133
args_format = '\\(:py:data:`~{name}`, {{}})'.format(name=annotation.__name__)
132134
role = 'func'
133-
elif full_name == 'typing.Union' and len(args) == 2 and type(None) in args:
134-
full_name = 'typing.Optional'
135-
args = tuple(x for x in args if x is not type(None)) # noqa: E721
135+
elif full_name == 'typing.Union' and type(None) in args:
136+
if len(args) == 2:
137+
full_name = 'typing.Optional'
138+
args = tuple(x for x in args if x is not type(None)) # noqa: E721
139+
elif not simplify_optional_unions:
140+
full_name = 'typing.Optional'
141+
args_format = '\\[:py:data:`{prefix}typing.Union`\\[{{}}]]'.format(prefix=prefix)
142+
args = tuple(x for x in args if x is not type(None)) # noqa: E721
136143
elif full_name == 'typing.Callable' and args and args[0] is not ...:
137-
formatted_args = '\\[\\[' + ', '.join(format_annotation(arg) for arg in args[:-1]) + ']'
138-
formatted_args += ', ' + format_annotation(args[-1]) + ']'
144+
formatted_args = '\\[\\[' + ', '.join(
145+
format_annotation(
146+
arg, simplify_optional_unions=simplify_optional_unions)
147+
for arg in args[:-1]) + ']'
148+
formatted_args += ', ' + format_annotation(
149+
args[-1], simplify_optional_unions=simplify_optional_unions) + ']'
139150
elif full_name == 'typing.Literal':
140151
formatted_args = '\\[' + ', '.join(repr(arg) for arg in args) + ']'
141152

142153
if args and not formatted_args:
143-
formatted_args = args_format.format(', '.join(format_annotation(arg, fully_qualified)
144-
for arg in args))
154+
formatted_args = args_format.format(', '.join(
155+
format_annotation(arg, fully_qualified, simplify_optional_unions)
156+
for arg in args))
145157

146158
return ':py:{role}:`{prefix}{full_name}`{formatted_args}'.format(
147159
role=role, prefix=prefix, full_name=full_name, formatted_args=formatted_args)
@@ -382,7 +394,9 @@ def process_docstring(app, what, name, obj, options, lines):
382394
argname = '{}\\_'.format(argname[:-1])
383395

384396
formatted_annotation = format_annotation(
385-
annotation, fully_qualified=app.config.typehints_fully_qualified)
397+
annotation,
398+
fully_qualified=app.config.typehints_fully_qualified,
399+
simplify_optional_unions=app.config.simplify_optional_unions)
386400

387401
searchfor = [':{} {}:'.format(field, argname)
388402
for field in ('param', 'parameter', 'arg', 'argument')]
@@ -409,7 +423,9 @@ def process_docstring(app, what, name, obj, options, lines):
409423
return
410424

411425
formatted_annotation = format_annotation(
412-
type_hints['return'], fully_qualified=app.config.typehints_fully_qualified)
426+
type_hints['return'], fully_qualified=app.config.typehints_fully_qualified,
427+
simplify_optional_unions=app.config.simplify_optional_unions
428+
)
413429

414430
insert_index = len(lines)
415431
for i, line in enumerate(lines):
@@ -439,6 +455,7 @@ def setup(app):
439455
app.add_config_value('always_document_param_types', False, 'html')
440456
app.add_config_value('typehints_fully_qualified', False, 'env')
441457
app.add_config_value('typehints_document_rtype', True, 'env')
458+
app.add_config_value('simplify_optional_unions', True, 'env')
442459
app.connect('builder-inited', builder_ready)
443460
app.connect('autodoc-process-signature', process_signature)
444461
app.connect('autodoc-process-docstring', process_docstring)

tests/test_sphinx_autodoc_typehints.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,11 +126,15 @@ def test_parse_annotation(annotation, module, class_name, args):
126126
(Union, ':py:data:`~typing.Union`'),
127127
(Union[str, bool], ':py:data:`~typing.Union`\\[:py:class:`str`, '
128128
':py:class:`bool`]'),
129+
(Union[str, bool, None], ':py:data:`~typing.Union`\\[:py:class:`str`, '
130+
':py:class:`bool`, ``None``]'),
129131
pytest.param(Union[str, Any], ':py:data:`~typing.Union`\\[:py:class:`str`, '
130132
':py:data:`~typing.Any`]',
131133
marks=pytest.mark.skipif((3, 5, 0) <= sys.version_info[:3] <= (3, 5, 2),
132134
reason='Union erases the str on 3.5.0 -> 3.5.2')),
133135
(Optional[str], ':py:data:`~typing.Optional`\\[:py:class:`str`]'),
136+
(Optional[Union[str, bool]], ':py:data:`~typing.Union`\\[:py:class:`str`, '
137+
':py:class:`bool`, ``None``]'),
134138
(Callable, ':py:data:`~typing.Callable`'),
135139
(Callable[..., int], ':py:data:`~typing.Callable`\\[..., :py:class:`int`]'),
136140
(Callable[[int], int], ':py:data:`~typing.Callable`\\[\\[:py:class:`int`], '
@@ -158,6 +162,27 @@ def test_format_annotation(inv, annotation, expected_result):
158162
result = format_annotation(annotation)
159163
assert result == expected_result
160164

165+
# Test with the "simplify_optional_unions" flag turned off:
166+
if re.match(r'^:py:data:`~typing\.Union`\\\[.*``None``.*\]', expected_result):
167+
# strip None - argument and copy string to avoid conflicts with
168+
# subsequent tests
169+
expected_result_not_simplified = expected_result.replace(', ``None``', '')
170+
# encapsulate Union in typing.Optional
171+
expected_result_not_simplified = ':py:data:`~typing.Optional`\\[' + \
172+
expected_result_not_simplified
173+
expected_result_not_simplified += ']'
174+
assert format_annotation(annotation, simplify_optional_unions=False) == \
175+
expected_result_not_simplified
176+
177+
# Test with the "fully_qualified" flag turned on
178+
if 'typing' in expected_result_not_simplified:
179+
expected_result_not_simplified = expected_result_not_simplified.replace('~typing',
180+
'typing')
181+
assert format_annotation(annotation,
182+
fully_qualified=True,
183+
simplify_optional_unions=False) == \
184+
expected_result_not_simplified
185+
161186
# Test with the "fully_qualified" flag turned on
162187
if 'typing' in expected_result or __name__ in expected_result:
163188
expected_result = expected_result.replace('~typing', 'typing')

0 commit comments

Comments
 (0)