Repository: tox-dev/sphinx-autodoc-typehints
Pull request #201 (2022-01-10 10:30:58)

Resolve type guard imports
Signed-off-by: Bernát Gábor <gaborjbernat@gmail.com>



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index dc0e09b..6b6d570 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 a63428e..7d4e670 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 ca5926d..4226ab5 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 @@ __all__ = [
     "normalize_source_lines",
     "process_docstring",
     "process_signature",
+    "backfill_type_hints",
 ]
diff --git a/whitelist.txt b/whitelist.txt
index 840c30c..5e9a0f6 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


Test patch:
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 0000000..bb939ea
--- /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 0000000..03ad07e
--- /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 0000000..f92eae8
--- /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 13deb9b..78e437f 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()