Skip to content

Commit 91c9f86

Browse files
authored
Resolve type guard imports (#201)
1 parent 1e25828 commit 91c9f86

File tree

8 files changed

+89
-32
lines changed

8 files changed

+89
-32
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
## dev
44

5+
- Resolve type guard imports before evaluating annotations for objects
56
- Fix crash when the `inspect` module returns an invalid python syntax source
67

78
## 1.14.1

setup.cfg

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ other =
7171
*\sphinx-autodoc-typehints
7272

7373
[coverage:report]
74-
fail_under = 55
74+
fail_under = 78
7575

7676
[coverage:html]
7777
show_contexts = true

src/sphinx_autodoc_typehints/__init__.py

Lines changed: 35 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import inspect
4+
import re
45
import sys
56
import textwrap
67
import typing
@@ -252,43 +253,48 @@ def _future_annotations_imported(obj: Any) -> bool:
252253

253254

254255
def get_all_type_hints(obj: Any, name: str) -> dict[str, Any]:
255-
rv = {}
256-
256+
result = _get_type_hint(name, obj)
257+
if result:
258+
return result
259+
result = backfill_type_hints(obj, name)
257260
try:
258-
rv = get_type_hints(obj)
259-
except (AttributeError, TypeError, RecursionError) as exc:
260-
# Introspecting a slot wrapper will raise TypeError, and and some recursive type
261-
# definitions will cause a RecursionError (https://github.com/python/typing/issues/574).
261+
obj.__annotations__ = result
262+
except (AttributeError, TypeError):
263+
return result
264+
return _get_type_hint(name, obj)
262265

263-
# If one is using PEP563 annotations, Python will raise a (e.g.,)
264-
# TypeError("TypeError: unsupported operand type(s) for |: 'type' and 'NoneType'")
265-
# on 'str | None', therefore we accept TypeErrors with that error message
266-
# if 'annotations' is imported from '__future__'.
267-
if isinstance(exc, TypeError) and _future_annotations_imported(obj) and "unsupported operand type" in str(exc):
268-
rv = obj.__annotations__
269-
except NameError as exc:
270-
_LOGGER.warning('Cannot resolve forward reference in type annotations of "%s": %s', name, exc)
271-
rv = obj.__annotations__
272266

273-
if rv:
274-
return rv
267+
_TYPE_GUARD_IMPORT_RE = re.compile(r"if (typing.)?TYPE_CHECKING:([\s\S]*?)(?=\n\S)")
268+
_TYPE_GUARD_IMPORTS_RESOLVED = set()
275269

276-
rv = backfill_type_hints(obj, name)
277270

278-
try:
279-
obj.__annotations__ = rv
280-
except (AttributeError, TypeError):
281-
return rv
271+
def _resolve_type_guarded_imports(obj: Any) -> None:
272+
if hasattr(obj, "__module__") and obj.__module__ not in _TYPE_GUARD_IMPORTS_RESOLVED:
273+
_TYPE_GUARD_IMPORTS_RESOLVED.add(obj.__module__)
274+
if obj.__module__ not in sys.builtin_module_names:
275+
module = inspect.getmodule(obj)
276+
if module:
277+
module_code = inspect.getsource(module)
278+
for (_, part) in _TYPE_GUARD_IMPORT_RE.findall(module_code):
279+
module_code = textwrap.dedent(part)
280+
exec(module_code, obj.__globals__)
282281

282+
283+
def _get_type_hint(name: str, obj: Any) -> dict[str, Any]:
284+
_resolve_type_guarded_imports(obj)
283285
try:
284-
rv = get_type_hints(obj)
285-
except (AttributeError, TypeError):
286-
pass
286+
result = get_type_hints(obj)
287+
except (AttributeError, TypeError, RecursionError) as exc:
288+
# TypeError - slot wrapper, PEP-563 when part of new syntax not supported
289+
# RecursionError - some recursive type definitions https://github.com/python/typing/issues/574
290+
if isinstance(exc, TypeError) and _future_annotations_imported(obj) and "unsupported operand type" in str(exc):
291+
result = obj.__annotations__
292+
else:
293+
result = {}
287294
except NameError as exc:
288295
_LOGGER.warning('Cannot resolve forward reference in type annotations of "%s": %s', name, exc)
289-
rv = obj.__annotations__
290-
291-
return rv
296+
result = obj.__annotations__
297+
return result
292298

293299

294300
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]:
305311

306312
def _one_child(module: Module) -> stmt | None:
307313
children = module.body # use the body to ignore type comments
308-
309314
if len(children) != 1:
310315
_LOGGER.warning('Did not get exactly one node from AST for "%s", got %s', name, len(children))
311316
return None
312-
313317
return children[0]
314318

315319
try:
@@ -526,4 +530,5 @@ def setup(app: Sphinx) -> dict[str, bool]:
526530
"normalize_source_lines",
527531
"process_docstring",
528532
"process_signature",
533+
"backfill_type_hints",
529534
]
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import pathlib
2+
import sys
3+
4+
master_doc = "index"
5+
sys.path.insert(0, str(pathlib.Path(__file__).parent))
6+
extensions = [
7+
"sphinx.ext.autodoc",
8+
"sphinx_autodoc_typehints",
9+
]
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"""Module demonstrating imports that are type guarded"""
2+
from __future__ import annotations
3+
4+
import typing
5+
from builtins import ValueError # handle does not have __module__
6+
from functools import cmp_to_key # has __module__ but cannot get module as is builtin
7+
from typing import TYPE_CHECKING
8+
9+
if TYPE_CHECKING:
10+
from decimal import Decimal
11+
from typing import Sequence
12+
13+
if typing.TYPE_CHECKING:
14+
from typing import AnyStr
15+
16+
17+
def a(f: Decimal, s: AnyStr) -> Sequence[AnyStr | Decimal]:
18+
"""
19+
Do.
20+
21+
:param f: first
22+
:param s: second
23+
:return: result
24+
"""
25+
return [f, s]
26+
27+
28+
__all__ = [
29+
"a",
30+
"ValueError",
31+
"cmp_to_key",
32+
]
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
.. automodule:: demo_typing_guard
2+
:members:

tests/test_sphinx_autodoc_typehints.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -761,3 +761,11 @@ def test_syntax_error_backfill() -> None:
761761
lambda x: x)
762762
# fmt: on
763763
backfill_type_hints(func, "func")
764+
765+
766+
@pytest.mark.sphinx("text", testroot="resolve-typing-guard")
767+
def test_resolve_typing_guard_imports(app: SphinxTestApp, status: StringIO, warning: StringIO) -> None:
768+
set_python_path()
769+
app.build()
770+
assert "build succeeded" in status.getvalue()
771+
assert not warning.getvalue()

whitelist.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ formatter
1616
func
1717
getmodule
1818
getsource
19+
globals
1920
idx
2021
inited
2122
inv
@@ -29,7 +30,6 @@ param
2930
parametrized
3031
params
3132
pathlib
32-
pep563
3333
pos
3434
prepend
3535
py310

0 commit comments

Comments
 (0)