From 1583c4b5ad05efb6d63668f70d513316c28badda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 31 Dec 2021 10:21:08 +0000 Subject: [PATCH 1/3] Fix the CI and 3.10 regressions (#188) --- setup.cfg | 2 +- sphinx_autodoc_typehints.py | 13 ++++++++++--- tests/test_sphinx_autodoc_typehints.py | 12 +++++++----- 3 files changed, 18 insertions(+), 9 deletions(-) diff --git a/setup.cfg b/setup.cfg index 4e57ff5b..78f6c02f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -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" diff --git a/sphinx_autodoc_typehints.py b/sphinx_autodoc_typehints.py index 69ebfe2e..1da3dadd 100644 --- a/sphinx_autodoc_typehints.py +++ b/sphinx_autodoc_typehints.py @@ -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 @@ -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__ @@ -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): @@ -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' diff --git a/tests/test_sphinx_autodoc_typehints.py b/tests/test_sphinx_autodoc_typehints.py index 0260c338..e06785bb 100644 --- a/tests/test_sphinx_autodoc_typehints.py +++ b/tests/test_sphinx_autodoc_typehints.py @@ -53,6 +53,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'), @@ -151,7 +154,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) @@ -352,7 +356,7 @@ class InnerClass Return type: "str" - property a_property + property a_property: str Property docstring @@ -506,9 +510,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) From 31a85fa998b370f255f2d3003e58e8353d40a612 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Wed, 13 Oct 2021 12:55:59 +0200 Subject: [PATCH 2/3] add a_method_with_py310_PEP563_annotations to tests --- ...fig.sample.yaml => .pre-commit-config.yaml | 0 sphinx_autodoc_typehints.py | 26 +++++- .../dummy_module_future_annotations.py | 11 +++ tests/roots/test-dummy/future_annotations.rst | 4 + tests/test_sphinx_autodoc_typehints.py | 79 +++++++++++++++++-- 5 files changed, 111 insertions(+), 9 deletions(-) rename pre-commit-config.sample.yaml => .pre-commit-config.yaml (100%) create mode 100644 tests/roots/test-dummy/dummy_module_future_annotations.py create mode 100644 tests/roots/test-dummy/future_annotations.rst diff --git a/pre-commit-config.sample.yaml b/.pre-commit-config.yaml similarity index 100% rename from pre-commit-config.sample.yaml rename to .pre-commit-config.yaml diff --git a/sphinx_autodoc_typehints.py b/sphinx_autodoc_typehints.py index 1da3dadd..c75d4d58 100644 --- a/sphinx_autodoc_typehints.py +++ b/sphinx_autodoc_typehints.py @@ -264,15 +264,37 @@ 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__ + CO_FUTURE_ANNOTATIONS = 0x1000000 # defined in cpython/Lib/__future__.py + 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) diff --git a/tests/roots/test-dummy/dummy_module_future_annotations.py b/tests/roots/test-dummy/dummy_module_future_annotations.py new file mode 100644 index 00000000..119159d3 --- /dev/null +++ b/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 + """ diff --git a/tests/roots/test-dummy/future_annotations.rst b/tests/roots/test-dummy/future_annotations.rst new file mode 100644 index 00000000..3d774cb8 --- /dev/null +++ b/tests/roots/test-dummy/future_annotations.rst @@ -0,0 +1,4 @@ +Dummy Module +============ + +.. autofunction:: dummy_module_future_annotations.function_with_py310_annotations diff --git a/tests/test_sphinx_autodoc_typehints.py b/tests/test_sphinx_autodoc_typehints.py index e06785bb..9333a53b 100644 --- a/tests/test_sphinx_autodoc_typehints.py +++ b/tests/test_sphinx_autodoc_typehints.py @@ -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 @@ -88,6 +89,10 @@ class Metaclass(type): pytest.param(A.Inner, __name__, 'A.Inner', (), id='Inner') ]) def test_parse_annotation(annotation, module, class_name, args): + if sys.version_info[:2] >= (3, 10) and annotation == W: + module = "test_sphinx_autodoc_typehints" + class_name = "W" + args = () assert get_annotation_module(annotation) == module assert get_annotation_class_name(annotation, module) == class_name assert get_annotation_args(annotation, module, class_name) == args @@ -130,7 +135,10 @@ def test_parse_annotation(annotation, module, class_name, args): ':py:data:`~typing.Any`]', marks=pytest.mark.skipif((3, 5, 0) <= sys.version_info[:3] <= (3, 5, 2), reason='Union erases the str on 3.5.0 -> 3.5.2')), - (Optional[str], ':py:data:`~typing.Optional`\\[:py:class:`str`]'), + (Optional[str], ':py:data:`~typing.Optional`\\' + ( + '[:py:class:`str`]' + if sys.version_info[:2] < (3, 10) else + '[:py:class:`str`, :py:obj:`None`]')), (Optional[Union[str, bool]], ':py:data:`~typing.Union`\\[:py:class:`str`, ' ':py:class:`bool`, :py:obj:`None`]'), (Callable, ':py:data:`~typing.Callable`'), @@ -227,17 +235,39 @@ 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", "None"]'), + ("(*bool*)", '("bool")'), + ("(*int*)", '("int")'), + (" str", ' "str"'), + ('"Optional"["str"]', '"Optional"["str", "None"]'), + ('"Optional"["Callable"[["int", "bytes"], "int"]]', + '"Optional"["Callable"[["int", "bytes"], "int"], "None"]'), + ]: + expected_contents = expected_contents.replace(old, new) + return expected_contents + + +@pytest.mark.parametrize('always_document_param_types', [True, False]) +@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 @@ -493,8 +523,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" @@ -527,7 +556,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(): From d34ee5cc0abfefdbc66c1a0ec4cf4c2fe4aac565 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sat, 1 Jan 2022 11:11:20 +0000 Subject: [PATCH 3/3] Fix tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Bernát Gábor --- sphinx_autodoc_typehints.py | 5 +++-- tests/test_sphinx_autodoc_typehints.py | 18 ++++++------------ 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/sphinx_autodoc_typehints.py b/sphinx_autodoc_typehints.py index c75d4d58..b9a0f27c 100644 --- a/sphinx_autodoc_typehints.py +++ b/sphinx_autodoc_typehints.py @@ -273,8 +273,9 @@ def _future_annotations_imported(obj): if _annotations is None: return False - # Make sure that annotations is imported from __future__ - CO_FUTURE_ANNOTATIONS = 0x1000000 # defined in cpython/Lib/__future__.py + # 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 diff --git a/tests/test_sphinx_autodoc_typehints.py b/tests/test_sphinx_autodoc_typehints.py index 9333a53b..f7686c0e 100644 --- a/tests/test_sphinx_autodoc_typehints.py +++ b/tests/test_sphinx_autodoc_typehints.py @@ -89,10 +89,6 @@ class Metaclass(type): pytest.param(A.Inner, __name__, 'A.Inner', (), id='Inner') ]) def test_parse_annotation(annotation, module, class_name, args): - if sys.version_info[:2] >= (3, 10) and annotation == W: - module = "test_sphinx_autodoc_typehints" - class_name = "W" - args = () assert get_annotation_module(annotation) == module assert get_annotation_class_name(annotation, module) == class_name assert get_annotation_args(annotation, module, class_name) == args @@ -135,10 +131,7 @@ def test_parse_annotation(annotation, module, class_name, args): ':py:data:`~typing.Any`]', marks=pytest.mark.skipif((3, 5, 0) <= sys.version_info[:3] <= (3, 5, 2), reason='Union erases the str on 3.5.0 -> 3.5.2')), - (Optional[str], ':py:data:`~typing.Optional`\\' + ( - '[:py:class:`str`]' - if sys.version_info[:2] < (3, 10) else - '[:py:class:`str`, :py:obj:`None`]')), + (Optional[str], ':py:data:`~typing.Optional`\\[:py:class:`str`]'), (Optional[Union[str, bool]], ':py:data:`~typing.Union`\\[:py:class:`str`, ' ':py:class:`bool`, :py:obj:`None`]'), (Callable, ':py:data:`~typing.Callable`'), @@ -246,19 +239,20 @@ def set_python_path(): def maybe_fix_py310(expected_contents): if sys.version_info[:2] >= (3, 10): for old, new in [ - ("*str** | **None*", '"Optional"["str", "None"]'), + ("*str** | **None*", '"Optional"["str"]'), ("(*bool*)", '("bool")'), ("(*int*)", '("int")'), (" str", ' "str"'), - ('"Optional"["str"]', '"Optional"["str", "None"]'), + ('"Optional"["str"]', '"Optional"["str"]'), ('"Optional"["Callable"[["int", "bytes"], "int"]]', - '"Optional"["Callable"[["int", "bytes"], "int"], "None"]'), + '"Optional"["Callable"[["int", "bytes"], "int"]]'), ]: expected_contents = expected_contents.replace(old, new) return expected_contents -@pytest.mark.parametrize('always_document_param_types', [True, False]) +@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):