diff --git a/.github/workflows/codeqa-test.yml b/.github/workflows/codeqa-test.yml index c068ee3..5f0bb03 100644 --- a/.github/workflows/codeqa-test.yml +++ b/.github/workflows/codeqa-test.yml @@ -19,9 +19,9 @@ jobs: path: ~/.cache/pip key: pip-lint - name: Install dependencies - run: pip install flake8 isort + run: pip install pyproject-flake8 isort - name: Run flake8 - run: flake8 sphinx_autodoc_typehints.py tests + run: pflake8 sphinx_autodoc_typehints.py tests - name: Run isort run: isort -c sphinx_autodoc_typehints.py tests @@ -31,7 +31,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest] - python-version: [3.6, 3.7, 3.8, 3.9, 3.10.0-alpha.5] + python-version: ["3.6", "3.7", "3.8", "3.9", "3.10"] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v2 diff --git a/pre-commit-config.sample.yaml b/.pre-commit-config.yaml similarity index 85% rename from pre-commit-config.sample.yaml rename to .pre-commit-config.yaml index 7f86457..00f00ce 100644 --- a/pre-commit-config.sample.yaml +++ b/.pre-commit-config.yaml @@ -22,3 +22,7 @@ repos: hooks: - id: isort additional_dependencies: [toml] +- repo: https://github.com/csachs/pyproject-flake8 + rev: v0.0.1a2.post1 + hooks: + - id: pyproject-flake8 diff --git a/pyproject.toml b/pyproject.toml index eeb699e..68d7eab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,9 @@ multi_line_output = 4 [tool.autopep8] max_line_length = 99 +[tool.flake8] +max-line-length = 99 + [tool.pytest.ini_options] addopts = "-rsx --tb=short" testpaths = ["tests"] diff --git a/setup.cfg b/setup.cfg index c872ff3..4e57ff5 100644 --- a/setup.cfg +++ b/setup.cfg @@ -38,10 +38,3 @@ test = Sphinx >= 3.2.0 type_comments = typed_ast >= 1.4.0; python_version < "3.8" - -[flake8] -max-line-length = 99 - -[tool:pytest] -addopts = -rsx --tb=short -testpaths = tests diff --git a/sphinx_autodoc_typehints.py b/sphinx_autodoc_typehints.py index 0b69b1c..5a4ed95 100644 --- a/sphinx_autodoc_typehints.py +++ b/sphinx_autodoc_typehints.py @@ -170,13 +170,19 @@ def normalize_source_lines(sourcelines: str) -> str: for i, l in enumerate(sourcelines): if l.lstrip().startswith("def"): idx = i + whitespace_separator = "def" break + elif l.lstrip().startswith("async def"): + idx = i + whitespace_separator = "async def" + break + else: return "\n".join(sourcelines) fn_def = sourcelines[idx] # Get a string representing the amount of leading whitespace - whitespace = fn_def.split("def")[0] + whitespace = fn_def.split(whitespace_separator)[0] # Add this leading whitespace to all lines before and after the `def` aligned_prefix = [whitespace + remove_prefix(s, whitespace) for s in sourcelines[:idx]] @@ -251,15 +257,37 @@ def process_signature(app, what: str, name: str, obj, options, signature, return 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 0000000..119159d --- /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 0000000..3d774cb --- /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 7ef8d73..ca43b71 100644 --- a/tests/test_sphinx_autodoc_typehints.py +++ b/tests/test_sphinx_autodoc_typehints.py @@ -6,13 +6,14 @@ import typing 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 from sphinx_autodoc_typehints import ( format_annotation, get_annotation_args, get_annotation_class_name, get_annotation_module, - process_docstring) + normalize_source_lines, process_docstring) T = TypeVar('T') U = TypeVar('U', covariant=True) @@ -85,6 +86,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 @@ -127,7 +132,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`'), @@ -151,7 +159,10 @@ 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, ':py:func:`~typing.NewType`\\(:py:data:`~W`, :py:class:`str`)' + if sys.version_info[:2] < (3, 10) else + ':py:class:`~test_sphinx_autodoc_typehints.W`' + ) ]) def test_format_annotation(inv, annotation, expected_result): result = format_annotation(annotation) @@ -223,17 +234,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 @@ -352,7 +385,7 @@ def test_sphinx_output(app, status, warning, always_document_param_types): Return type: "str" - property a_property + property a_property: str Property docstring @@ -489,8 +522,7 @@ def test_sphinx_output(app, status, warning, always_document_param_types): Method docstring. Parameters: - **x** ("Optional"["Callable"[["int", "bytes"], "int"]]) -- - foo + **x** ("Optional"["Callable"[["int", "bytes"], "int"]]) -- foo Return type: "ClassWithTypehintsNotInline" @@ -506,9 +538,7 @@ def test_sphinx_output(app, status, warning, always_document_param_types): 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) @@ -525,4 +555,56 @@ def test_sphinx_output(app, status, warning, always_document_param_types): **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(): + source = textwrap.dedent(""" + async def async_function(): + class InnerClass: + def __init__(self): pass + """) + + expected = textwrap.dedent(""" + async def async_function(): + class InnerClass: + def __init__(self): pass + """) + + assert normalize_source_lines(source) == expected diff --git a/tox.ini b/tox.ini index 54e9bb4..57e4a78 100644 --- a/tox.ini +++ b/tox.ini @@ -9,6 +9,6 @@ extras = test, type_comments commands = python -m pytest {posargs} [testenv:flake8] -deps = flake8 -commands = flake8 sphinx_autodoc_typehints.py tests +deps = pyproject-flake8 +commands = pflake8 sphinx_autodoc_typehints.py tests skip_install = true