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

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`. 

4 

5from __future__ import annotations 

6 

7import sys 

8from contextlib import contextmanager 

9from importlib import import_module 

10from typing import TYPE_CHECKING, Any 

11 

12if TYPE_CHECKING: 

13 from collections.abc import Iterator, Sequence 

14 from pathlib import Path 

15 

16 

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

19 

20 

21@contextmanager 

22def sys_path(*paths: str | Path) -> Iterator[None]: 

23 """Redefine `sys.path` temporarily. 

24 

25 Parameters: 

26 *paths: The paths to use when importing modules. 

27 If no paths are given, keep `sys.path` untouched. 

28 

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 

41 

42 

43def dynamic_import(import_path: str, import_paths: Sequence[str | Path] | None = None) -> Any: 

44 """Dynamically import the specified object. 

45 

46 It can be a module, class, method, function, attribute, 

47 nested arbitrarily. 

48 

49 It works like this: 

50 

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 

62 

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. 

66 

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. 

69 

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. 

78 

79 Parameters: 

80 import_path: The path of the object to import. 

81 import_paths: The (sys) paths to import the object from. 

82 

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. 

86 

87 Returns: 

88 The imported object. 

89 """ 

90 module_parts: list[str] = import_path.split(".") 

91 object_parts: list[str] = [] 

92 errors = [] 

93 

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)) 

108 

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 

122 

123 return value