Skip to content

Unrecoverable ModuleNotFoundError in _run_guarded_import escapes as a warning #768

Description

@djhoese

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"}.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions