Skip to content

Commit 04f3db8

Browse files
Allow py310 style annotations (PEP563) when using from __future__ import annotations (#184)
Co-authored-by: Bernát Gábor <[email protected]>
1 parent 1583c4b commit 04f3db8

File tree

5 files changed

+105
-8
lines changed

5 files changed

+105
-8
lines changed
File renamed without changes.

sphinx_autodoc_typehints.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -264,15 +264,38 @@ def _is_dataclass(name: str, what: str, qualname: str) -> bool:
264264
return stringify_signature(signature).replace('\\', '\\\\'), None
265265

266266

267+
def _future_annotations_imported(obj):
268+
if sys.version_info < (3, 7):
269+
# Only Python ≥ 3.7 supports PEP563.
270+
return False
271+
272+
_annotations = getattr(inspect.getmodule(obj), "annotations", None)
273+
if _annotations is None:
274+
return False
275+
276+
# Make sure that annotations is imported from __future__ - defined in cpython/Lib/__future__.py
277+
# annotations become strings at runtime
278+
CO_FUTURE_ANNOTATIONS = 0x100000 if sys.version_info[0:2] == (3, 7) else 0x1000000
279+
return _annotations.compiler_flag == CO_FUTURE_ANNOTATIONS
280+
281+
267282
def get_all_type_hints(obj, name):
268283
rv = {}
269284

270285
try:
271286
rv = get_type_hints(obj)
272-
except (AttributeError, TypeError, RecursionError):
287+
except (AttributeError, TypeError, RecursionError) as exc:
273288
# Introspecting a slot wrapper will raise TypeError, and and some recursive type
274289
# definitions will cause a RecursionError (https://github.com/python/typing/issues/574).
275-
pass
290+
291+
# If one is using PEP563 annotations, Python will raise a (e.g.,)
292+
# TypeError("TypeError: unsupported operand type(s) for |: 'type' and 'NoneType'")
293+
# on 'str | None', therefore we accept TypeErrors with that error message
294+
# if 'annotations' is imported from '__future__'.
295+
if (isinstance(exc, TypeError)
296+
and _future_annotations_imported(obj)
297+
and "unsupported operand type" in str(exc)):
298+
rv = obj.__annotations__
276299
except NameError as exc:
277300
logger.warning('Cannot resolve forward reference in type annotations of "%s": %s',
278301
name, exc)
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from __future__ import annotations
2+
3+
4+
def function_with_py310_annotations(self, x: bool, y: int, z: str | None = None) -> str:
5+
"""
6+
Method docstring.
7+
8+
:param x: foo
9+
:param y: bar
10+
:param z: baz
11+
"""
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Dummy Module
2+
============
3+
4+
.. autofunction:: dummy_module_future_annotations.function_with_py310_annotations

tests/test_sphinx_autodoc_typehints.py

Lines changed: 65 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from typing import (
77
IO, Any, AnyStr, Callable, Dict, Generic, Mapping, Match, NewType, Optional, Pattern, Tuple,
88
Type, TypeVar, Union)
9+
from unittest.mock import patch
910

1011
import pytest
1112
import typing_extensions
@@ -227,17 +228,40 @@ def test_process_docstring_slot_wrapper():
227228
assert not lines
228229

229230

230-
@pytest.mark.parametrize('always_document_param_types', [True, False])
231-
@pytest.mark.sphinx('text', testroot='dummy')
232-
def test_sphinx_output(app, status, warning, always_document_param_types):
231+
def set_python_path():
233232
test_path = pathlib.Path(__file__).parent
234233

235234
# Add test directory to sys.path to allow imports of dummy module.
236235
if str(test_path) not in sys.path:
237236
sys.path.insert(0, str(test_path))
238237

238+
239+
def maybe_fix_py310(expected_contents):
240+
if sys.version_info[:2] >= (3, 10):
241+
for old, new in [
242+
("*str** | **None*", '"Optional"["str"]'),
243+
("(*bool*)", '("bool")'),
244+
("(*int*)", '("int")'),
245+
(" str", ' "str"'),
246+
('"Optional"["str"]', '"Optional"["str"]'),
247+
('"Optional"["Callable"[["int", "bytes"], "int"]]',
248+
'"Optional"["Callable"[["int", "bytes"], "int"]]'),
249+
]:
250+
expected_contents = expected_contents.replace(old, new)
251+
return expected_contents
252+
253+
254+
@pytest.mark.parametrize('always_document_param_types', [True, False],
255+
ids=['doc_param_type', 'no_doc_param_type'])
256+
@pytest.mark.sphinx('text', testroot='dummy')
257+
@patch('sphinx.writers.text.MAXWIDTH', 2000)
258+
def test_sphinx_output(app, status, warning, always_document_param_types):
259+
set_python_path()
260+
239261
app.config.always_document_param_types = always_document_param_types
240262
app.config.autodoc_mock_imports = ['mailbox']
263+
if sys.version_info < (3, 7):
264+
app.config.autodoc_mock_imports.append('dummy_module_future_annotations')
241265
app.build()
242266

243267
assert 'build succeeded' in status.getvalue() # Build succeeded
@@ -493,8 +517,7 @@ class dummy_module.ClassWithTypehintsNotInline(x=None)
493517
Method docstring.
494518
495519
Parameters:
496-
**x** ("Optional"["Callable"[["int", "bytes"], "int"]]) --
497-
foo
520+
**x** ("Optional"["Callable"[["int", "bytes"], "int"]]) -- foo
498521
499522
Return type:
500523
"ClassWithTypehintsNotInline"
@@ -527,7 +550,43 @@ class dummy_module.DataClass(x)
527550
**x** ("Mailbox") -- function
528551
''')
529552
expected_contents = expected_contents.format(**format_args).replace('–', '--')
530-
assert text_contents == expected_contents
553+
assert text_contents == maybe_fix_py310(expected_contents)
554+
555+
556+
@pytest.mark.skipif(sys.version_info < (3, 7),
557+
reason="Future annotations are not implemented in Python < 3.7")
558+
@pytest.mark.sphinx('text', testroot='dummy')
559+
@patch('sphinx.writers.text.MAXWIDTH', 2000)
560+
def test_sphinx_output_future_annotations(app, status, warning):
561+
set_python_path()
562+
563+
app.config.master_doc = "future_annotations"
564+
app.build()
565+
566+
assert 'build succeeded' in status.getvalue() # Build succeeded
567+
568+
text_path = pathlib.Path(app.srcdir) / '_build' / 'text' / 'future_annotations.txt'
569+
with text_path.open('r') as f:
570+
text_contents = f.read().replace('–', '--')
571+
expected_contents = textwrap.dedent('''\
572+
Dummy Module
573+
************
574+
575+
dummy_module_future_annotations.function_with_py310_annotations(self, x, y, z=None)
576+
577+
Method docstring.
578+
579+
Parameters:
580+
* **x** (*bool*) -- foo
581+
582+
* **y** (*int*) -- bar
583+
584+
* **z** (*str** | **None*) -- baz
585+
586+
Return type:
587+
str
588+
''')
589+
assert text_contents == maybe_fix_py310(expected_contents)
531590

532591

533592
def test_normalize_source_lines_async_def():

0 commit comments

Comments
 (0)