Close #9555: autosummary: Improve error messages on failure to load target object

This commit is contained in:
Takeshi KOMIYA 2021-12-30 23:44:24 +09:00
parent 8ddf3f09c6
commit 9039991a3b
3 changed files with 79 additions and 34 deletions

View File

@ -23,6 +23,7 @@ Features added
``__all__`` attribute if :confval:`autosummary_ignore_module_all` is set to ``__all__`` attribute if :confval:`autosummary_ignore_module_all` is set to
``False``. The default behaviour is unchanged. Autogen also now supports ``False``. The default behaviour is unchanged. Autogen also now supports
this behavior with the ``--respect-module-all`` switch. this behavior with the ``--respect-module-all`` switch.
* #9555: autosummary: Improve error messages on failure to load target object
* #9800: extlinks: Emit warning if a hardcoded link is replaceable * #9800: extlinks: Emit warning if a hardcoded link is replaceable
by an extlink, suggesting a replacement. by an extlink, suggesting a replacement.
* #9961: html: Support nested <kbd> HTML elements in other HTML builders * #9961: html: Support nested <kbd> HTML elements in other HTML builders

View File

@ -61,7 +61,7 @@ import warnings
from inspect import Parameter from inspect import Parameter
from os import path from os import path
from types import ModuleType from types import ModuleType
from typing import Any, Dict, List, Optional, Tuple, Type, cast from typing import Any, Dict, List, Optional, Sequence, Tuple, Type, cast
from docutils import nodes from docutils import nodes
from docutils.nodes import Element, Node, system_message from docutils.nodes import Element, Node, system_message
@ -306,15 +306,18 @@ class Autosummary(SphinxDirective):
def import_by_name(self, name: str, prefixes: List[str]) -> Tuple[str, Any, Any, str]: def import_by_name(self, name: str, prefixes: List[str]) -> Tuple[str, Any, Any, str]:
with mock(self.config.autosummary_mock_imports): with mock(self.config.autosummary_mock_imports):
try: try:
return import_by_name(name, prefixes) return import_by_name(name, prefixes, grouped_exception=True)
except ImportError as exc: except ImportExceptionGroup as exc:
# check existence of instance attribute # check existence of instance attribute
try: try:
return import_ivar_by_name(name, prefixes) return import_ivar_by_name(name, prefixes)
except ImportError: except ImportError as exc2:
pass if exc2.__cause__:
errors: List[BaseException] = exc.exceptions + [exc2.__cause__]
else:
errors = exc.exceptions + [exc2]
raise exc # re-raise ImportError if instance attribute not found raise ImportExceptionGroup(exc.args[0], errors)
def create_documenter(self, app: Sphinx, obj: Any, def create_documenter(self, app: Sphinx, obj: Any,
parent: Any, full_name: str) -> "Documenter": parent: Any, full_name: str) -> "Documenter":
@ -344,9 +347,10 @@ class Autosummary(SphinxDirective):
try: try:
real_name, obj, parent, modname = self.import_by_name(name, prefixes=prefixes) real_name, obj, parent, modname = self.import_by_name(name, prefixes=prefixes)
except ImportError: except ImportExceptionGroup as exc:
logger.warning(__('autosummary: failed to import %s'), name, errors = list(set("* %s: %s" % (type(e).__name__, e) for e in exc.exceptions))
location=self.get_location()) logger.warning(__('autosummary: failed to import %s.\nPossible hints:\n%s'),
name, '\n'.join(errors), location=self.get_location())
continue continue
self.bridge.result = StringList() # initialize for each documenter self.bridge.result = StringList() # initialize for each documenter
@ -620,6 +624,18 @@ def limited_join(sep: str, items: List[str], max_chars: int = 30,
# -- Importing items ----------------------------------------------------------- # -- Importing items -----------------------------------------------------------
class ImportExceptionGroup(Exception):
"""Exceptions raised during importing the target objects.
It contains an error messages and a list of exceptions as its arguments.
"""
def __init__(self, message: Optional[str], exceptions: Sequence[BaseException]):
super().__init__(message)
self.exceptions = list(exceptions)
def get_import_prefixes_from_env(env: BuildEnvironment) -> List[str]: def get_import_prefixes_from_env(env: BuildEnvironment) -> List[str]:
""" """
Obtain current Python import prefixes (for `import_by_name`) Obtain current Python import prefixes (for `import_by_name`)
@ -641,26 +657,38 @@ def get_import_prefixes_from_env(env: BuildEnvironment) -> List[str]:
return prefixes return prefixes
def import_by_name(name: str, prefixes: List[str] = [None]) -> Tuple[str, Any, Any, str]: def import_by_name(name: str, prefixes: List[str] = [None], grouped_exception: bool = False
) -> Tuple[str, Any, Any, str]:
"""Import a Python object that has the given *name*, under one of the """Import a Python object that has the given *name*, under one of the
*prefixes*. The first name that succeeds is used. *prefixes*. The first name that succeeds is used.
""" """
tried = [] tried = []
errors: List[ImportExceptionGroup] = []
for prefix in prefixes: for prefix in prefixes:
try: try:
if prefix: if prefix:
prefixed_name = '.'.join([prefix, name]) prefixed_name = '.'.join([prefix, name])
else: else:
prefixed_name = name prefixed_name = name
obj, parent, modname = _import_by_name(prefixed_name) obj, parent, modname = _import_by_name(prefixed_name, grouped_exception)
return prefixed_name, obj, parent, modname return prefixed_name, obj, parent, modname
except ImportError: except ImportError:
tried.append(prefixed_name) tried.append(prefixed_name)
raise ImportError('no module named %s' % ' or '.join(tried)) except ImportExceptionGroup as exc:
tried.append(prefixed_name)
errors.append(exc)
if grouped_exception:
exceptions: List[BaseException] = sum((e.exceptions for e in errors), [])
raise ImportExceptionGroup('no module named %s' % ' or '.join(tried), exceptions)
else:
raise ImportError('no module named %s' % ' or '.join(tried))
def _import_by_name(name: str) -> Tuple[Any, Any, str]: def _import_by_name(name: str, grouped_exception: bool = False) -> Tuple[Any, Any, str]:
"""Import a Python object given its full name.""" """Import a Python object given its full name."""
errors: List[BaseException] = []
try: try:
name_parts = name.split('.') name_parts = name.split('.')
@ -670,8 +698,8 @@ def _import_by_name(name: str) -> Tuple[Any, Any, str]:
try: try:
mod = import_module(modname) mod = import_module(modname)
return getattr(mod, name_parts[-1]), mod, modname return getattr(mod, name_parts[-1]), mod, modname
except (ImportError, IndexError, AttributeError): except (ImportError, IndexError, AttributeError) as exc:
pass errors.append(exc.__cause__ or exc)
# ... then as MODNAME, MODNAME.OBJ1, MODNAME.OBJ1.OBJ2, ... # ... then as MODNAME, MODNAME.OBJ1, MODNAME.OBJ1.OBJ2, ...
last_j = 0 last_j = 0
@ -681,8 +709,8 @@ def _import_by_name(name: str) -> Tuple[Any, Any, str]:
modname = '.'.join(name_parts[:j]) modname = '.'.join(name_parts[:j])
try: try:
import_module(modname) import_module(modname)
except ImportError: except ImportError as exc:
continue errors.append(exc.__cause__ or exc)
if modname in sys.modules: if modname in sys.modules:
break break
@ -696,25 +724,32 @@ def _import_by_name(name: str) -> Tuple[Any, Any, str]:
return obj, parent, modname return obj, parent, modname
else: else:
return sys.modules[modname], None, modname return sys.modules[modname], None, modname
except (ValueError, ImportError, AttributeError, KeyError) as e: except (ValueError, ImportError, AttributeError, KeyError) as exc:
raise ImportError(*e.args) from e errors.append(exc)
if grouped_exception:
raise ImportExceptionGroup('', errors)
else:
raise ImportError(*exc.args) from exc
def import_ivar_by_name(name: str, prefixes: List[str] = [None]) -> Tuple[str, Any, Any, str]: def import_ivar_by_name(name: str, prefixes: List[str] = [None],
grouped_exception: bool = False) -> Tuple[str, Any, Any, str]:
"""Import an instance variable that has the given *name*, under one of the """Import an instance variable that has the given *name*, under one of the
*prefixes*. The first name that succeeds is used. *prefixes*. The first name that succeeds is used.
""" """
try: try:
name, attr = name.rsplit(".", 1) name, attr = name.rsplit(".", 1)
real_name, obj, parent, modname = import_by_name(name, prefixes) real_name, obj, parent, modname = import_by_name(name, prefixes, grouped_exception)
qualname = real_name.replace(modname + ".", "") qualname = real_name.replace(modname + ".", "")
analyzer = ModuleAnalyzer.for_module(getattr(obj, '__module__', modname)) analyzer = ModuleAnalyzer.for_module(getattr(obj, '__module__', modname))
analyzer.analyze() analyzer.analyze()
# check for presence in `annotations` to include dataclass attributes # check for presence in `annotations` to include dataclass attributes
if (qualname, attr) in analyzer.attr_docs or (qualname, attr) in analyzer.annotations: if (qualname, attr) in analyzer.attr_docs or (qualname, attr) in analyzer.annotations:
return real_name + "." + attr, INSTANCEATTR, obj, modname return real_name + "." + attr, INSTANCEATTR, obj, modname
except (ImportError, ValueError, PycodeError): except (ImportError, ValueError, PycodeError) as exc:
pass raise ImportError from exc
except ImportExceptionGroup:
raise # pass through it as is
raise ImportError raise ImportError
@ -739,8 +774,8 @@ class AutoLink(SphinxRole):
try: try:
# try to import object by name # try to import object by name
prefixes = get_import_prefixes_from_env(self.env) prefixes = get_import_prefixes_from_env(self.env)
import_by_name(pending_xref['reftarget'], prefixes) import_by_name(pending_xref['reftarget'], prefixes, grouped_exception=True)
except ImportError: except ImportExceptionGroup:
literal = cast(nodes.literal, pending_xref[0]) literal = cast(nodes.literal, pending_xref[0])
objects[0] = nodes.emphasis(self.rawtext, literal.astext(), objects[0] = nodes.emphasis(self.rawtext, literal.astext(),
classes=literal['classes']) classes=literal['classes'])

View File

@ -41,7 +41,8 @@ from sphinx.config import Config
from sphinx.deprecation import RemovedInSphinx50Warning from sphinx.deprecation import RemovedInSphinx50Warning
from sphinx.ext.autodoc import Documenter from sphinx.ext.autodoc import Documenter
from sphinx.ext.autodoc.importer import import_module from sphinx.ext.autodoc.importer import import_module
from sphinx.ext.autosummary import get_documenter, import_by_name, import_ivar_by_name from sphinx.ext.autosummary import (ImportExceptionGroup, get_documenter, import_by_name,
import_ivar_by_name)
from sphinx.locale import __ from sphinx.locale import __
from sphinx.pycode import ModuleAnalyzer, PycodeError from sphinx.pycode import ModuleAnalyzer, PycodeError
from sphinx.registry import SphinxComponentRegistry from sphinx.registry import SphinxComponentRegistry
@ -430,15 +431,22 @@ def generate_autosummary_docs(sources: List[str], output_dir: str = None,
ensuredir(path) ensuredir(path)
try: try:
name, obj, parent, modname = import_by_name(entry.name) name, obj, parent, modname = import_by_name(entry.name, grouped_exception=True)
qualname = name.replace(modname + ".", "") qualname = name.replace(modname + ".", "")
except ImportError as e: except ImportExceptionGroup as exc:
try: try:
# try to importl as an instance attribute # try to import as an instance attribute
name, obj, parent, modname = import_ivar_by_name(entry.name) name, obj, parent, modname = import_ivar_by_name(entry.name)
qualname = name.replace(modname + ".", "") qualname = name.replace(modname + ".", "")
except ImportError: except ImportError as exc2:
logger.warning(__('[autosummary] failed to import %r: %s') % (entry.name, e)) if exc2.__cause__:
exceptions: List[BaseException] = exc.exceptions + [exc2.__cause__]
else:
exceptions = exc.exceptions + [exc2]
errors = list(set("* %s: %s" % (type(e).__name__, e) for e in exceptions))
logger.warning(__('[autosummary] failed to import %s.\nPossible hints:\n%s'),
entry.name, '\n'.join(errors))
continue continue
context: Dict[str, Any] = {} context: Dict[str, Any] = {}
@ -500,13 +508,14 @@ def find_autosummary_in_docstring(name: str, module: str = None, filename: str =
RemovedInSphinx50Warning, stacklevel=2) RemovedInSphinx50Warning, stacklevel=2)
try: try:
real_name, obj, parent, modname = import_by_name(name) real_name, obj, parent, modname = import_by_name(name, grouped_exception=True)
lines = pydoc.getdoc(obj).splitlines() lines = pydoc.getdoc(obj).splitlines()
return find_autosummary_in_lines(lines, module=name, filename=filename) return find_autosummary_in_lines(lines, module=name, filename=filename)
except AttributeError: except AttributeError:
pass pass
except ImportError as e: except ImportExceptionGroup as exc:
print("Failed to import '%s': %s" % (name, e)) errors = list(set("* %s: %s" % (type(e).__name__, e) for e in exc.exceptions))
print('Failed to import %s.\nPossible hints:\n%s' % (name, '\n'.join(errors)))
except SystemExit: except SystemExit:
print("Failed to import '%s'; the module executes module level " print("Failed to import '%s'; the module executes module level "
"statement and it might call sys.exit()." % name) "statement and it might call sys.exit()." % name)