Skip to content

Commit b22134d

Browse files
authored
Merge pull request #10031 from tk0miya/9555_ImportExceptionGroup_for_autosummary
Close #9555: autosummary: Improve error messages on failure to load target object
2 parents eed0730 + 9039991 commit b22134d

File tree

3 files changed

+79
-34
lines changed

3 files changed

+79
-34
lines changed

CHANGES

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ Features added
2323
``__all__`` attribute if :confval:`autosummary_ignore_module_all` is set to
2424
``False``. The default behaviour is unchanged. Autogen also now supports
2525
this behavior with the ``--respect-module-all`` switch.
26+
* #9555: autosummary: Improve error messages on failure to load target object
2627
* #9800: extlinks: Emit warning if a hardcoded link is replaceable
2728
by an extlink, suggesting a replacement.
2829
* #9961: html: Support nested <kbd> HTML elements in other HTML builders

sphinx/ext/autosummary/__init__.py

Lines changed: 60 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@
6161
from inspect import Parameter
6262
from os import path
6363
from types import ModuleType
64-
from typing import Any, Dict, List, Optional, Tuple, Type, cast
64+
from typing import Any, Dict, List, Optional, Sequence, Tuple, Type, cast
6565

6666
from docutils import nodes
6767
from docutils.nodes import Element, Node, system_message
@@ -306,15 +306,18 @@ def run(self) -> List[Node]:
306306
def import_by_name(self, name: str, prefixes: List[str]) -> Tuple[str, Any, Any, str]:
307307
with mock(self.config.autosummary_mock_imports):
308308
try:
309-
return import_by_name(name, prefixes)
310-
except ImportError as exc:
309+
return import_by_name(name, prefixes, grouped_exception=True)
310+
except ImportExceptionGroup as exc:
311311
# check existence of instance attribute
312312
try:
313313
return import_ivar_by_name(name, prefixes)
314-
except ImportError:
315-
pass
314+
except ImportError as exc2:
315+
if exc2.__cause__:
316+
errors: List[BaseException] = exc.exceptions + [exc2.__cause__]
317+
else:
318+
errors = exc.exceptions + [exc2]
316319

317-
raise exc # re-raise ImportError if instance attribute not found
320+
raise ImportExceptionGroup(exc.args[0], errors)
318321

319322
def create_documenter(self, app: Sphinx, obj: Any,
320323
parent: Any, full_name: str) -> "Documenter":
@@ -344,9 +347,10 @@ def get_items(self, names: List[str]) -> List[Tuple[str, str, str, str]]:
344347

345348
try:
346349
real_name, obj, parent, modname = self.import_by_name(name, prefixes=prefixes)
347-
except ImportError:
348-
logger.warning(__('autosummary: failed to import %s'), name,
349-
location=self.get_location())
350+
except ImportExceptionGroup as exc:
351+
errors = list(set("* %s: %s" % (type(e).__name__, e) for e in exc.exceptions))
352+
logger.warning(__('autosummary: failed to import %s.\nPossible hints:\n%s'),
353+
name, '\n'.join(errors), location=self.get_location())
350354
continue
351355

352356
self.bridge.result = StringList() # initialize for each documenter
@@ -620,6 +624,18 @@ def limited_join(sep: str, items: List[str], max_chars: int = 30,
620624

621625
# -- Importing items -----------------------------------------------------------
622626

627+
628+
class ImportExceptionGroup(Exception):
629+
"""Exceptions raised during importing the target objects.
630+
631+
It contains an error messages and a list of exceptions as its arguments.
632+
"""
633+
634+
def __init__(self, message: Optional[str], exceptions: Sequence[BaseException]):
635+
super().__init__(message)
636+
self.exceptions = list(exceptions)
637+
638+
623639
def get_import_prefixes_from_env(env: BuildEnvironment) -> List[str]:
624640
"""
625641
Obtain current Python import prefixes (for `import_by_name`)
@@ -641,26 +657,38 @@ def get_import_prefixes_from_env(env: BuildEnvironment) -> List[str]:
641657
return prefixes
642658

643659

644-
def import_by_name(name: str, prefixes: List[str] = [None]) -> Tuple[str, Any, Any, str]:
660+
def import_by_name(name: str, prefixes: List[str] = [None], grouped_exception: bool = False
661+
) -> Tuple[str, Any, Any, str]:
645662
"""Import a Python object that has the given *name*, under one of the
646663
*prefixes*. The first name that succeeds is used.
647664
"""
648665
tried = []
666+
errors: List[ImportExceptionGroup] = []
649667
for prefix in prefixes:
650668
try:
651669
if prefix:
652670
prefixed_name = '.'.join([prefix, name])
653671
else:
654672
prefixed_name = name
655-
obj, parent, modname = _import_by_name(prefixed_name)
673+
obj, parent, modname = _import_by_name(prefixed_name, grouped_exception)
656674
return prefixed_name, obj, parent, modname
657675
except ImportError:
658676
tried.append(prefixed_name)
659-
raise ImportError('no module named %s' % ' or '.join(tried))
677+
except ImportExceptionGroup as exc:
678+
tried.append(prefixed_name)
679+
errors.append(exc)
680+
681+
if grouped_exception:
682+
exceptions: List[BaseException] = sum((e.exceptions for e in errors), [])
683+
raise ImportExceptionGroup('no module named %s' % ' or '.join(tried), exceptions)
684+
else:
685+
raise ImportError('no module named %s' % ' or '.join(tried))
660686

661687

662-
def _import_by_name(name: str) -> Tuple[Any, Any, str]:
688+
def _import_by_name(name: str, grouped_exception: bool = False) -> Tuple[Any, Any, str]:
663689
"""Import a Python object given its full name."""
690+
errors: List[BaseException] = []
691+
664692
try:
665693
name_parts = name.split('.')
666694

@@ -670,8 +698,8 @@ def _import_by_name(name: str) -> Tuple[Any, Any, str]:
670698
try:
671699
mod = import_module(modname)
672700
return getattr(mod, name_parts[-1]), mod, modname
673-
except (ImportError, IndexError, AttributeError):
674-
pass
701+
except (ImportError, IndexError, AttributeError) as exc:
702+
errors.append(exc.__cause__ or exc)
675703

676704
# ... then as MODNAME, MODNAME.OBJ1, MODNAME.OBJ1.OBJ2, ...
677705
last_j = 0
@@ -681,8 +709,8 @@ def _import_by_name(name: str) -> Tuple[Any, Any, str]:
681709
modname = '.'.join(name_parts[:j])
682710
try:
683711
import_module(modname)
684-
except ImportError:
685-
continue
712+
except ImportError as exc:
713+
errors.append(exc.__cause__ or exc)
686714

687715
if modname in sys.modules:
688716
break
@@ -696,25 +724,32 @@ def _import_by_name(name: str) -> Tuple[Any, Any, str]:
696724
return obj, parent, modname
697725
else:
698726
return sys.modules[modname], None, modname
699-
except (ValueError, ImportError, AttributeError, KeyError) as e:
700-
raise ImportError(*e.args) from e
727+
except (ValueError, ImportError, AttributeError, KeyError) as exc:
728+
errors.append(exc)
729+
if grouped_exception:
730+
raise ImportExceptionGroup('', errors)
731+
else:
732+
raise ImportError(*exc.args) from exc
701733

702734

703-
def import_ivar_by_name(name: str, prefixes: List[str] = [None]) -> Tuple[str, Any, Any, str]:
735+
def import_ivar_by_name(name: str, prefixes: List[str] = [None],
736+
grouped_exception: bool = False) -> Tuple[str, Any, Any, str]:
704737
"""Import an instance variable that has the given *name*, under one of the
705738
*prefixes*. The first name that succeeds is used.
706739
"""
707740
try:
708741
name, attr = name.rsplit(".", 1)
709-
real_name, obj, parent, modname = import_by_name(name, prefixes)
742+
real_name, obj, parent, modname = import_by_name(name, prefixes, grouped_exception)
710743
qualname = real_name.replace(modname + ".", "")
711744
analyzer = ModuleAnalyzer.for_module(getattr(obj, '__module__', modname))
712745
analyzer.analyze()
713746
# check for presence in `annotations` to include dataclass attributes
714747
if (qualname, attr) in analyzer.attr_docs or (qualname, attr) in analyzer.annotations:
715748
return real_name + "." + attr, INSTANCEATTR, obj, modname
716-
except (ImportError, ValueError, PycodeError):
717-
pass
749+
except (ImportError, ValueError, PycodeError) as exc:
750+
raise ImportError from exc
751+
except ImportExceptionGroup:
752+
raise # pass through it as is
718753

719754
raise ImportError
720755

@@ -739,8 +774,8 @@ def run(self) -> Tuple[List[Node], List[system_message]]:
739774
try:
740775
# try to import object by name
741776
prefixes = get_import_prefixes_from_env(self.env)
742-
import_by_name(pending_xref['reftarget'], prefixes)
743-
except ImportError:
777+
import_by_name(pending_xref['reftarget'], prefixes, grouped_exception=True)
778+
except ImportExceptionGroup:
744779
literal = cast(nodes.literal, pending_xref[0])
745780
objects[0] = nodes.emphasis(self.rawtext, literal.astext(),
746781
classes=literal['classes'])

sphinx/ext/autosummary/generate.py

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@
4141
from sphinx.deprecation import RemovedInSphinx50Warning
4242
from sphinx.ext.autodoc import Documenter
4343
from sphinx.ext.autodoc.importer import import_module
44-
from sphinx.ext.autosummary import get_documenter, import_by_name, import_ivar_by_name
44+
from sphinx.ext.autosummary import (ImportExceptionGroup, get_documenter, import_by_name,
45+
import_ivar_by_name)
4546
from sphinx.locale import __
4647
from sphinx.pycode import ModuleAnalyzer, PycodeError
4748
from sphinx.registry import SphinxComponentRegistry
@@ -430,15 +431,22 @@ def generate_autosummary_docs(sources: List[str], output_dir: str = None,
430431
ensuredir(path)
431432

432433
try:
433-
name, obj, parent, modname = import_by_name(entry.name)
434+
name, obj, parent, modname = import_by_name(entry.name, grouped_exception=True)
434435
qualname = name.replace(modname + ".", "")
435-
except ImportError as e:
436+
except ImportExceptionGroup as exc:
436437
try:
437-
# try to importl as an instance attribute
438+
# try to import as an instance attribute
438439
name, obj, parent, modname = import_ivar_by_name(entry.name)
439440
qualname = name.replace(modname + ".", "")
440-
except ImportError:
441-
logger.warning(__('[autosummary] failed to import %r: %s') % (entry.name, e))
441+
except ImportError as exc2:
442+
if exc2.__cause__:
443+
exceptions: List[BaseException] = exc.exceptions + [exc2.__cause__]
444+
else:
445+
exceptions = exc.exceptions + [exc2]
446+
447+
errors = list(set("* %s: %s" % (type(e).__name__, e) for e in exceptions))
448+
logger.warning(__('[autosummary] failed to import %s.\nPossible hints:\n%s'),
449+
entry.name, '\n'.join(errors))
442450
continue
443451

444452
context: Dict[str, Any] = {}
@@ -500,13 +508,14 @@ def find_autosummary_in_docstring(name: str, module: str = None, filename: str =
500508
RemovedInSphinx50Warning, stacklevel=2)
501509

502510
try:
503-
real_name, obj, parent, modname = import_by_name(name)
511+
real_name, obj, parent, modname = import_by_name(name, grouped_exception=True)
504512
lines = pydoc.getdoc(obj).splitlines()
505513
return find_autosummary_in_lines(lines, module=name, filename=filename)
506514
except AttributeError:
507515
pass
508-
except ImportError as e:
509-
print("Failed to import '%s': %s" % (name, e))
516+
except ImportExceptionGroup as exc:
517+
errors = list(set("* %s: %s" % (type(e).__name__, e) for e in exc.exceptions))
518+
print('Failed to import %s.\nPossible hints:\n%s' % (name, '\n'.join(errors)))
510519
except SystemExit:
511520
print("Failed to import '%s'; the module executes module level "
512521
"statement and it might call sys.exit()." % name)

0 commit comments

Comments
 (0)