Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
File renamed without changes.
27 changes: 25 additions & 2 deletions sphinx_autodoc_typehints.py
Original file line number Diff line number Diff line change
Expand Up @@ -264,15 +264,38 @@ def _is_dataclass(name: str, what: str, qualname: str) -> bool:
return stringify_signature(signature).replace('\\', '\\\\'), None


def _future_annotations_imported(obj):
if sys.version_info < (3, 7):
# Only Python ≥ 3.7 supports PEP563.
return False

_annotations = getattr(inspect.getmodule(obj), "annotations", None)
if _annotations is None:
return False

# Make sure that annotations is imported from __future__ - defined in cpython/Lib/__future__.py
# annotations become strings at runtime
CO_FUTURE_ANNOTATIONS = 0x100000 if sys.version_info[0:2] == (3, 7) else 0x1000000
return _annotations.compiler_flag == CO_FUTURE_ANNOTATIONS


def get_all_type_hints(obj, name):
rv = {}

try:
rv = get_type_hints(obj)
except (AttributeError, TypeError, RecursionError):
except (AttributeError, TypeError, RecursionError) as exc:
# Introspecting a slot wrapper will raise TypeError, and and some recursive type
# definitions will cause a RecursionError (https://github.com/python/typing/issues/574).
pass

# If one is using PEP563 annotations, Python will raise a (e.g.,)
# TypeError("TypeError: unsupported operand type(s) for |: 'type' and 'NoneType'")
# on 'str | None', therefore we accept TypeErrors with that error message
# if 'annotations' is imported from '__future__'.
if (isinstance(exc, TypeError)
and _future_annotations_imported(obj)
and "unsupported operand type" in str(exc)):
rv = obj.__annotations__
except NameError as exc:
logger.warning('Cannot resolve forward reference in type annotations of "%s": %s',
name, exc)
Expand Down
11 changes: 11 additions & 0 deletions tests/roots/test-dummy/dummy_module_future_annotations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from __future__ import annotations


def function_with_py310_annotations(self, x: bool, y: int, z: str | None = None) -> str:
"""
Method docstring.

:param x: foo
:param y: bar
:param z: baz
"""
4 changes: 4 additions & 0 deletions tests/roots/test-dummy/future_annotations.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Dummy Module
============

.. autofunction:: dummy_module_future_annotations.function_with_py310_annotations
71 changes: 65 additions & 6 deletions tests/test_sphinx_autodoc_typehints.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from typing import (
IO, Any, AnyStr, Callable, Dict, Generic, Mapping, Match, NewType, Optional, Pattern, Tuple,
Type, TypeVar, Union)
from unittest.mock import patch

import pytest
import typing_extensions
Expand Down Expand Up @@ -227,17 +228,40 @@ def test_process_docstring_slot_wrapper():
assert not lines


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

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


def maybe_fix_py310(expected_contents):
if sys.version_info[:2] >= (3, 10):
for old, new in [
("*str** | **None*", '"Optional"["str"]'),
("(*bool*)", '("bool")'),
("(*int*)", '("int")'),
(" str", ' "str"'),
('"Optional"["str"]', '"Optional"["str"]'),
('"Optional"["Callable"[["int", "bytes"], "int"]]',
'"Optional"["Callable"[["int", "bytes"], "int"]]'),
]:
expected_contents = expected_contents.replace(old, new)
return expected_contents


@pytest.mark.parametrize('always_document_param_types', [True, False],
ids=['doc_param_type', 'no_doc_param_type'])
@pytest.mark.sphinx('text', testroot='dummy')
@patch('sphinx.writers.text.MAXWIDTH', 2000)
def test_sphinx_output(app, status, warning, always_document_param_types):
set_python_path()

app.config.always_document_param_types = always_document_param_types
app.config.autodoc_mock_imports = ['mailbox']
if sys.version_info < (3, 7):
app.config.autodoc_mock_imports.append('dummy_module_future_annotations')
app.build()

assert 'build succeeded' in status.getvalue() # Build succeeded
Expand Down Expand Up @@ -493,8 +517,7 @@ class dummy_module.ClassWithTypehintsNotInline(x=None)
Method docstring.

Parameters:
**x** ("Optional"["Callable"[["int", "bytes"], "int"]]) --
foo
**x** ("Optional"["Callable"[["int", "bytes"], "int"]]) -- foo

Return type:
"ClassWithTypehintsNotInline"
Expand Down Expand Up @@ -527,7 +550,43 @@ class dummy_module.DataClass(x)
**x** ("Mailbox") -- function
''')
expected_contents = expected_contents.format(**format_args).replace('–', '--')
assert text_contents == expected_contents
assert text_contents == maybe_fix_py310(expected_contents)


@pytest.mark.skipif(sys.version_info < (3, 7),
reason="Future annotations are not implemented in Python < 3.7")
@pytest.mark.sphinx('text', testroot='dummy')
@patch('sphinx.writers.text.MAXWIDTH', 2000)
def test_sphinx_output_future_annotations(app, status, warning):
set_python_path()

app.config.master_doc = "future_annotations"
app.build()

assert 'build succeeded' in status.getvalue() # Build succeeded

text_path = pathlib.Path(app.srcdir) / '_build' / 'text' / 'future_annotations.txt'
with text_path.open('r') as f:
text_contents = f.read().replace('–', '--')
expected_contents = textwrap.dedent('''\
Dummy Module
************

dummy_module_future_annotations.function_with_py310_annotations(self, x, y, z=None)

Method docstring.

Parameters:
* **x** (*bool*) -- foo

* **y** (*int*) -- bar

* **z** (*str** | **None*) -- baz

Return type:
str
''')
assert text_contents == maybe_fix_py310(expected_contents)


def test_normalize_source_lines_async_def():
Expand Down