Coverage for src/_griffe/importer.py: 95.56%
39 statements
« prev ^ index » next coverage.py v7.6.2, created at 2024-10-12 01:34 +0200
« prev ^ index » next coverage.py v7.6.2, created at 2024-10-12 01:34 +0200
1# This module contains utilities to dynamically import objects.
2# These utilities are used by our [`Inspector`][griffe.Inspector] to dynamically import objects
3# specified as Python paths, like `package.module.Class.method`.
5from __future__ import annotations
7import sys
8from contextlib import contextmanager
9from importlib import import_module
10from typing import TYPE_CHECKING, Any
12if TYPE_CHECKING:
13 from collections.abc import Iterator, Sequence
14 from pathlib import Path
17def _error_details(error: BaseException, objpath: str) -> str:
18 return f"With sys.path = {sys.path!r}, accessing {objpath!r} raises {error.__class__.__name__}: {error}"
21@contextmanager
22def sys_path(*paths: str | Path) -> Iterator[None]:
23 """Redefine `sys.path` temporarily.
25 Parameters:
26 *paths: The paths to use when importing modules.
27 If no paths are given, keep `sys.path` untouched.
29 Yields:
30 Nothing.
31 """
32 if not paths:
33 yield
34 return
35 old_path = sys.path
36 sys.path = [str(path) for path in paths]
37 try:
38 yield
39 finally:
40 sys.path = old_path
43def dynamic_import(import_path: str, import_paths: Sequence[str | Path] | None = None) -> Any:
44 """Dynamically import the specified object.
46 It can be a module, class, method, function, attribute,
47 nested arbitrarily.
49 It works like this:
51 - for a given object path `a.b.x.y`
52 - it tries to import `a.b.x.y` as a module (with `importlib.import_module`)
53 - if it fails, it tries again with `a.b.x`, storing `y`
54 - then `a.b`, storing `x.y`
55 - then `a`, storing `b.x.y`
56 - if nothing worked, it raises an error
57 - if one of the iteration worked, it moves on, and...
58 - it tries to get the remaining (stored) parts with `getattr`
59 - for example it gets `b` from `a`, then `x` from `b`, etc.
60 - if a single attribute access fails, it raises an error
61 - if everything worked, it returns the last obtained attribute
63 Since the function potentially tries multiple things before succeeding,
64 all errors happening along the way are recorded, and re-emitted with
65 an `ImportError` when it fails, to let users know what was tried.
67 IMPORTANT: The paths given through the `import_paths` parameter are used
68 to temporarily patch `sys.path`: this function is therefore not thread-safe.
70 IMPORTANT: The paths given as `import_paths` must be *correct*.
71 The contents of `sys.path` must be consistent to what a user of the imported code
72 would expect. Given a set of paths, if the import fails for a user, it will fail here too,
73 with potentially unintuitive errors. If we wanted to make this function more robust,
74 we could add a loop to "roll the window" of given paths, shifting them to the left
75 (for example: `("/a/a", "/a/b", "/a/c/")`, then `("/a/b", "/a/c", "/a/a/")`,
76 then `("/a/c", "/a/a", "/a/b/")`), to make sure each entry is given highest priority
77 at least once, maintaining relative order, but we deem this unnecessary for now.
79 Parameters:
80 import_path: The path of the object to import.
81 import_paths: The (sys) paths to import the object from.
83 Raises:
84 ModuleNotFoundError: When the object's module could not be found.
85 ImportError: When there was an import error or when couldn't get the attribute.
87 Returns:
88 The imported object.
89 """
90 module_parts: list[str] = import_path.split(".")
91 object_parts: list[str] = []
92 errors = []
94 with sys_path(*(import_paths or ())):
95 while module_parts:
96 module_path = ".".join(module_parts)
97 try:
98 module = import_module(module_path)
99 except BaseException as error: # noqa: BLE001
100 # pyo3's PanicException can only be caught with BaseException.
101 # We do want to catch base exceptions anyway (exit, interrupt, etc.).
102 errors.append(_error_details(error, module_path))
103 object_parts.insert(0, module_parts.pop(-1))
104 else:
105 break
106 else:
107 raise ImportError("; ".join(errors))
109 # Sometimes extra dependencies are not installed,
110 # so importing the leaf module fails with a ModuleNotFoundError,
111 # or later `getattr` triggers additional code that fails.
112 # In these cases, and for consistency, we always re-raise an ImportError
113 # instead of an any other exception (it's called "dynamic import" after all).
114 # See https://github.com/mkdocstrings/mkdocstrings/issues/380
115 value = module
116 for part in object_parts:
117 try:
118 value = getattr(value, part)
119 except BaseException as error: # noqa: BLE001
120 errors.append(_error_details(error, module_path + ":" + ".".join(object_parts)))
121 raise ImportError("; ".join(errors)) # noqa: B904
123 return value