Skip to content

Commit

Permalink
Merge d34ee5c into e285adc
Browse files Browse the repository at this point in the history
  • Loading branch information
basnijholt committed Jan 1, 2022
2 parents e285adc + d34ee5c commit 8279faa
Show file tree
Hide file tree
Showing 6 changed files with 123 additions and 17 deletions.
File renamed without changes.
2 changes: 1 addition & 1 deletion setup.cfg
Expand Up @@ -35,6 +35,6 @@ test =
typing_extensions >= 3.5
dataclasses; python_version == "3.6"
sphobjinv >= 2.0
Sphinx >= 3.2.0
Sphinx >= 4.3
type_comments =
typed_ast >= 1.4.0; python_version < "3.8"
40 changes: 35 additions & 5 deletions sphinx_autodoc_typehints.py
Expand Up @@ -2,7 +2,7 @@
import sys
import textwrap
import typing
from typing import Any, AnyStr, Tuple, TypeVar, get_type_hints
from typing import Any, AnyStr, NewType, Tuple, TypeVar, get_type_hints

from sphinx.util import logging
from sphinx.util.inspect import signature as Signature
Expand All @@ -18,6 +18,9 @@ def get_annotation_module(annotation) -> str:
if annotation is None:
return 'builtins'

if sys.version_info >= (3, 10) and isinstance(annotation, NewType):
return 'typing'

if hasattr(annotation, '__module__'):
return annotation.__module__

Expand All @@ -35,7 +38,9 @@ def get_annotation_class_name(annotation, module: str) -> str:
return 'Any'
elif annotation is AnyStr:
return 'AnyStr'
elif inspect.isfunction(annotation) and hasattr(annotation, '__supertype__'):
elif (sys.version_info < (3, 10) and inspect.isfunction(annotation)
and hasattr(annotation, '__supertype__')) or \
(sys.version_info >= (3, 10) and isinstance(annotation, NewType)):
return 'NewType'

if getattr(annotation, '__qualname__', None):
Expand Down Expand Up @@ -120,7 +125,9 @@ def format_annotation(annotation,
# Some types require special handling
if full_name == 'typing.NewType':
args_format = '\\(:py:data:`~{name}`, {{}})'.format(name=annotation.__name__)
role = 'func'
role = 'class' if sys.version_info >= (3, 10) else 'func'
elif full_name == 'typing.Optional':
args = tuple(x for x in args if x is not type(None)) # noqa: E721
elif full_name == 'typing.Union' and type(None) in args:
if len(args) == 2:
full_name = 'typing.Optional'
Expand Down Expand Up @@ -257,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
@@ -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
@@ -0,0 +1,4 @@
Dummy Module
============

.. autofunction:: dummy_module_future_annotations.function_with_py310_annotations
83 changes: 72 additions & 11 deletions tests/test_sphinx_autodoc_typehints.py
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 @@ -53,6 +54,9 @@ class Metaclass(type):
pass


PY310_PLUS = sys.version_info >= (3, 10)


@pytest.mark.parametrize('annotation, module, class_name, args', [
pytest.param(str, 'builtins', 'str', (), id='str'),
pytest.param(None, 'builtins', 'None', (), id='None'),
Expand Down Expand Up @@ -151,7 +155,8 @@ def test_parse_annotation(annotation, module, class_name, args):
(D, ':py:class:`~%s.D`' % __name__),
(E, ':py:class:`~%s.E`' % __name__),
(E[int], ':py:class:`~%s.E`\\[:py:class:`int`]' % __name__),
(W, ':py:func:`~typing.NewType`\\(:py:data:`~W`, :py:class:`str`)')
(W, f':py:{"class" if PY310_PLUS else "func"}:'
f'`~typing.NewType`\\(:py:data:`~W`, :py:class:`str`)')
])
def test_format_annotation(inv, annotation, expected_result):
result = format_annotation(annotation)
Expand Down Expand Up @@ -223,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 @@ -352,7 +380,7 @@ class InnerClass
Return type:
"str"
property a_property
property a_property: str
Property docstring
Expand Down Expand Up @@ -489,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 All @@ -506,9 +533,7 @@ class dummy_module.DataClass(x)
Class docstring.{undoc_params_0}
__init__(x)
Initialize self. See help(type(self)) for accurate signature.{undoc_params_1}
__init__(x){undoc_params_1}
@dummy_module.Decorator(func)
Expand All @@ -525,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

0 comments on commit 8279faa

Please sign in to comment.