This is a continuation of #741, #743, and #751. It goes along with my comment on #751 here but is a little different. I had Claude help me with this but I'm rewording it because of how long Claude's explanation is.
Summary
In a TYPE_CHECKING guard of a dependency library (ex. xarray), an optional dependency of that library (ex. iris) will cause a warning due to the optional dependency not being available. Below is Claude's reproducer.
WARNING: Failed guarded type import in 'xarray.core.dataarray': ModuleNotFoundError("No module named 'iris'") [sphinx_autodoc_typehints.guarded_import]
I'm not sure if this is intended to warn (see workarounds below) or intended to work. Sorry for the amount of AI in this, I wasn't sure how else to shorten it and the information seemed useful.
Minimal reproducer
Details
src/baremod.py:
"""A module whose only xarray contact is a plain module-level import."""
from xarray import DataArray
def uses_it(x):
"""Do nothing interesting."""
return x
doc/conf.py:
import os, sys
sys.path.insert(0, os.path.abspath("../src"))
project = "mini"
extensions = ["sphinx.ext.autodoc", "sphinx_autodoc_typehints"]
doc/index.rst:
Mini
====
.. automodule:: baremod
:members:
Then, in an environment with xarray but without iris:
$ python -m sphinx -b html doc out
WARNING: Failed guarded type import in 'xarray.core.dataarray': ModuleNotFoundError("No module named 'iris'") [sphinx_autodoc_typehints.guarded_import]
build succeeded, 1 warning.
Versions
|
|
| sphinx-autodoc-typehints |
3.13.6 |
| Sphinx |
9.1.0 |
| Python |
3.13.15 |
| xarray |
2026.7.0 (any version with the iris guard) |
| iris |
not installed (intentionally) |
Root cause
Claude's thoughts on why this happens (take with a grain of salt)...
Details
sphinx_autodoc_typehints/_resolver/_type_hints.py, _run_guarded_import (v3.13.6, ~line 254):
def _run_guarded_import(autodoc_mock_imports: list[str], obj: Any, guarded_code: str) -> None:
ns = getattr(obj, "__globals__", obj.__dict__)
try:
with mock(autodoc_mock_imports):
exec(guarded_code, ns)
except ImportError as exc:
if not exc.name:
return
resolve_type_guarded_imports(autodoc_mock_imports, importlib.import_module(exc.name)) # (A)
try:
with mock(autodoc_mock_imports):
exec(guarded_code, ns)
except ImportError:
pass # (B)
The retry exists for a legitimate case: the guarded module does exist, but its own guarded
imports have not been resolved yet, so importing it and resolving its guards makes the retry
succeed.
That logic assumes exc.name is importable. When the module is genuinely missing,
importlib.import_module(exc.name) at (A) raises ModuleNotFoundError — a subclass of
ImportError — while the original ImportError is still being handled. Nothing guards line
(A), so the new exception propagates straight out of _run_guarded_import. The handler at
(B), which looks intended to swallow precisely "still cannot import it", is never reached.
_execute_guarded_code catches it with a broad except Exception and, since the offending
statement is an import, calls _warn_guarded_import:
for statement in statements:
try:
_run_guarded_import(autodoc_mock_imports, obj, ast.unparse(statement))
except Exception as exc:
if any(isinstance(node, ast.Import | ast.ImportFrom) for node in ast.walk(statement)):
_warn_guarded_import(obj, exc) # <-- here
else:
...
Confirmed by calling the internals directly — the escaping traceback is line 264 (the
importlib.import_module in the handler), not line 260 (the original exec):
File ".../_resolver/_type_hints.py", line 260, in _run_guarded_import
exec(guarded_code, ns)
ModuleNotFoundError: No module named 'iris'
During handling of the above exception, another exception occurred:
File ".../_resolver/_type_hints.py", line 264, in _run_guarded_import
resolve_type_guarded_imports(autodoc_mock_imports, importlib.import_module(exc.name))
~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^
ModuleNotFoundError: No module named 'iris'
The one-statement-at-a-time change in #741 fixed name-stranding but did not address this: a
missing optional dependency is still routed through recovery logic that cannot handle it.
Proposed fix
And Claude's proposed fix:
Details
Guard the recovery import. A module that is not installed is unrecoverable here, so return
quietly (or log at debug level) rather than letting the failure become a warning:
except ImportError as exc:
if not exc.name:
return
try:
# The retry only helps when the target module exists but its own guards are
# unresolved. A module that simply is not installed is unrecoverable here.
failed_module = importlib.import_module(exc.name)
except ImportError:
return
resolve_type_guarded_imports(autodoc_mock_imports, failed_module)
try:
with mock(autodoc_mock_imports):
exec(guarded_code, ns)
except ImportError:
pass
Verified by monkeypatching this over a full real-world docs build (pyresample): the
guarded_import warning disappears, no new warnings appear, and the rendered HTML is
byte-identical to a build where the missing module is instead listed in
autodoc_mock_imports.
Workarounds
The missing optional dependency can also be added to autodoc_mock_imports in conf.py or the third-party "thing" being documented (DataArray in my case) can be added to autodoc_default_options = {"exclude-members": "DataArray"}.
This is a continuation of #741, #743, and #751. It goes along with my comment on #751 here but is a little different. I had Claude help me with this but I'm rewording it because of how long Claude's explanation is.
Summary
In a
TYPE_CHECKINGguard of a dependency library (ex. xarray), an optional dependency of that library (ex. iris) will cause a warning due to the optional dependency not being available. Below is Claude's reproducer.I'm not sure if this is intended to warn (see workarounds below) or intended to work. Sorry for the amount of AI in this, I wasn't sure how else to shorten it and the information seemed useful.
Minimal reproducer
Details
src/baremod.py:doc/conf.py:doc/index.rst:Then, in an environment with
xarraybut withoutiris:Versions
irisguard)Root cause
Claude's thoughts on why this happens (take with a grain of salt)...
Details
sphinx_autodoc_typehints/_resolver/_type_hints.py,_run_guarded_import(v3.13.6, ~line 254):The retry exists for a legitimate case: the guarded module does exist, but its own guarded
imports have not been resolved yet, so importing it and resolving its guards makes the retry
succeed.
That logic assumes
exc.nameis importable. When the module is genuinely missing,importlib.import_module(exc.name)at (A) raisesModuleNotFoundError— a subclass ofImportError— while the originalImportErroris still being handled. Nothing guards line(A), so the new exception propagates straight out of
_run_guarded_import. The handler at(B), which looks intended to swallow precisely "still cannot import it", is never reached.
_execute_guarded_codecatches it with a broadexcept Exceptionand, since the offendingstatement is an import, calls
_warn_guarded_import:Confirmed by calling the internals directly — the escaping traceback is line 264 (the
importlib.import_modulein the handler), not line 260 (the originalexec):The one-statement-at-a-time change in #741 fixed name-stranding but did not address this: a
missing optional dependency is still routed through recovery logic that cannot handle it.
Proposed fix
And Claude's proposed fix:
Details
Guard the recovery import. A module that is not installed is unrecoverable here, so return
quietly (or log at debug level) rather than letting the failure become a warning:
Verified by monkeypatching this over a full real-world docs build (pyresample): the
guarded_importwarning disappears, no new warnings appear, and the rendered HTML isbyte-identical to a build where the missing module is instead listed in
autodoc_mock_imports.Workarounds
The missing optional dependency can also be added to
autodoc_mock_importsinconf.pyor the third-party "thing" being documented (DataArray in my case) can be added toautodoc_default_options = {"exclude-members": "DataArray"}.