diff --git a/CHANGELOG.md b/CHANGELOG.md index dc0e09b1..6b6d570c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## dev +- Resolve type guard imports before evaluating annotations for objects - Fix crash when the `inspect` module returns an invalid python syntax source ## 1.14.1 diff --git a/setup.cfg b/setup.cfg index a63428e2..7d4e670f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -71,7 +71,7 @@ other = *\sphinx-autodoc-typehints [coverage:report] -fail_under = 55 +fail_under = 78 [coverage:html] show_contexts = true diff --git a/src/sphinx_autodoc_typehints/__init__.py b/src/sphinx_autodoc_typehints/__init__.py index ca5926d5..4226ab59 100644 --- a/src/sphinx_autodoc_typehints/__init__.py +++ b/src/sphinx_autodoc_typehints/__init__.py @@ -1,6 +1,7 @@ from __future__ import annotations import inspect +import re import sys import textwrap import typing @@ -252,43 +253,48 @@ def _future_annotations_imported(obj: Any) -> bool: def get_all_type_hints(obj: Any, name: str) -> dict[str, Any]: - rv = {} - + result = _get_type_hint(name, obj) + if result: + return result + result = backfill_type_hints(obj, name) try: - rv = get_type_hints(obj) - 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). + obj.__annotations__ = result + except (AttributeError, TypeError): + return result + return _get_type_hint(name, obj) - # 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) - rv = obj.__annotations__ - if rv: - return rv +_TYPE_GUARD_IMPORT_RE = re.compile(r"if (typing.)?TYPE_CHECKING:([\s\S]*?)(?=\n\S)") +_TYPE_GUARD_IMPORTS_RESOLVED = set() - rv = backfill_type_hints(obj, name) - try: - obj.__annotations__ = rv - except (AttributeError, TypeError): - return rv +def _resolve_type_guarded_imports(obj: Any) -> None: + if hasattr(obj, "__module__") and obj.__module__ not in _TYPE_GUARD_IMPORTS_RESOLVED: + _TYPE_GUARD_IMPORTS_RESOLVED.add(obj.__module__) + if obj.__module__ not in sys.builtin_module_names: + module = inspect.getmodule(obj) + if module: + module_code = inspect.getsource(module) + for (_, part) in _TYPE_GUARD_IMPORT_RE.findall(module_code): + module_code = textwrap.dedent(part) + exec(module_code, obj.__globals__) + +def _get_type_hint(name: str, obj: Any) -> dict[str, Any]: + _resolve_type_guarded_imports(obj) try: - rv = get_type_hints(obj) - except (AttributeError, TypeError): - pass + result = get_type_hints(obj) + except (AttributeError, TypeError, RecursionError) as exc: + # TypeError - slot wrapper, PEP-563 when part of new syntax not supported + # RecursionError - some recursive type definitions https://github.com/python/typing/issues/574 + if isinstance(exc, TypeError) and _future_annotations_imported(obj) and "unsupported operand type" in str(exc): + result = obj.__annotations__ + else: + result = {} except NameError as exc: _LOGGER.warning('Cannot resolve forward reference in type annotations of "%s": %s', name, exc) - rv = obj.__annotations__ - - return rv + result = obj.__annotations__ + return result def backfill_type_hints(obj: Any, name: str) -> dict[str, Any]: @@ -305,11 +311,9 @@ def backfill_type_hints(obj: Any, name: str) -> dict[str, Any]: def _one_child(module: Module) -> stmt | None: children = module.body # use the body to ignore type comments - if len(children) != 1: _LOGGER.warning('Did not get exactly one node from AST for "%s", got %s', name, len(children)) return None - return children[0] try: @@ -526,4 +530,5 @@ def setup(app: Sphinx) -> dict[str, bool]: "normalize_source_lines", "process_docstring", "process_signature", + "backfill_type_hints", ] diff --git a/tests/roots/test-resolve-typing-guard/conf.py b/tests/roots/test-resolve-typing-guard/conf.py new file mode 100644 index 00000000..bb939ea4 --- /dev/null +++ b/tests/roots/test-resolve-typing-guard/conf.py @@ -0,0 +1,9 @@ +import pathlib +import sys + +master_doc = "index" +sys.path.insert(0, str(pathlib.Path(__file__).parent)) +extensions = [ + "sphinx.ext.autodoc", + "sphinx_autodoc_typehints", +] diff --git a/tests/roots/test-resolve-typing-guard/demo_typing_guard.py b/tests/roots/test-resolve-typing-guard/demo_typing_guard.py new file mode 100644 index 00000000..03ad07e0 --- /dev/null +++ b/tests/roots/test-resolve-typing-guard/demo_typing_guard.py @@ -0,0 +1,32 @@ +"""Module demonstrating imports that are type guarded""" +from __future__ import annotations + +import typing +from builtins import ValueError # handle does not have __module__ +from functools import cmp_to_key # has __module__ but cannot get module as is builtin +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from decimal import Decimal + from typing import Sequence + +if typing.TYPE_CHECKING: + from typing import AnyStr + + +def a(f: Decimal, s: AnyStr) -> Sequence[AnyStr | Decimal]: + """ + Do. + + :param f: first + :param s: second + :return: result + """ + return [f, s] + + +__all__ = [ + "a", + "ValueError", + "cmp_to_key", +] diff --git a/tests/roots/test-resolve-typing-guard/index.rst b/tests/roots/test-resolve-typing-guard/index.rst new file mode 100644 index 00000000..f92eae81 --- /dev/null +++ b/tests/roots/test-resolve-typing-guard/index.rst @@ -0,0 +1,2 @@ +.. automodule:: demo_typing_guard + :members: diff --git a/tests/test_sphinx_autodoc_typehints.py b/tests/test_sphinx_autodoc_typehints.py index 13deb9b7..78e437f5 100644 --- a/tests/test_sphinx_autodoc_typehints.py +++ b/tests/test_sphinx_autodoc_typehints.py @@ -761,3 +761,11 @@ def test_syntax_error_backfill() -> None: lambda x: x) # fmt: on backfill_type_hints(func, "func") + + +@pytest.mark.sphinx("text", testroot="resolve-typing-guard") +def test_resolve_typing_guard_imports(app: SphinxTestApp, status: StringIO, warning: StringIO) -> None: + set_python_path() + app.build() + assert "build succeeded" in status.getvalue() + assert not warning.getvalue() diff --git a/whitelist.txt b/whitelist.txt index 840c30cb..5e9a0f6a 100644 --- a/whitelist.txt +++ b/whitelist.txt @@ -16,6 +16,7 @@ formatter func getmodule getsource +globals idx inited inv @@ -29,7 +30,6 @@ param parametrized params pathlib -pep563 pos prepend py310