import_hooks.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275
  1. """Implements a post-import hook mechanism.
  2. Styled as per PEP-369. Note that it doesn't cope with modules being reloaded.
  3. Note: This file is based on
  4. https://github.com/GrahamDumpleton/wrapt/blob/1.12.1/src/wrapt/importer.py
  5. and manual backports of later patches up to 1.15.0 in the wrapt repository
  6. (with slight modifications).
  7. """
  8. from __future__ import annotations
  9. import sys
  10. import threading
  11. from importlib.util import find_spec
  12. from typing import Any, Callable
  13. # The dictionary registering any post import hooks to be triggered once
  14. # the target module has been imported. Once a module has been imported
  15. # and the hooks fired, the list of hooks recorded against the target
  16. # module will be truncated but the list left in the dictionary. This
  17. # acts as a flag to indicate that the module had already been imported.
  18. _post_import_hooks: dict = {}
  19. _post_import_hooks_init: bool = False
  20. _post_import_hooks_lock = threading.RLock()
  21. # Register a new post import hook for the target module name. This
  22. # differs from the PEP-369 implementation in that it also allows the
  23. # hook function to be specified as a string consisting of the name of
  24. # the callback in the form 'module:function'. This will result in a
  25. # proxy callback being registered which will defer loading of the
  26. # specified module containing the callback function until required.
  27. def _create_import_hook_from_string(name: str) -> Callable:
  28. def import_hook(module: Any) -> Callable:
  29. module_name, function = name.split(":")
  30. attrs = function.split(".")
  31. __import__(module_name)
  32. callback = sys.modules[module_name]
  33. for attr in attrs:
  34. callback = getattr(callback, attr)
  35. return callback(module) # type: ignore
  36. return import_hook
  37. def register_post_import_hook(hook: str | Callable, hook_id: str, name: str) -> None:
  38. # Create a deferred import hook if hook is a string name rather than
  39. # a callable function.
  40. if isinstance(hook, (str,)):
  41. hook = _create_import_hook_from_string(hook)
  42. # Automatically install the import hook finder if it has not already
  43. # been installed.
  44. with _post_import_hooks_lock:
  45. global _post_import_hooks_init
  46. if not _post_import_hooks_init:
  47. _post_import_hooks_init = True
  48. sys.meta_path.insert(0, ImportHookFinder()) # type: ignore
  49. # Check if the module is already imported. If not, register the hook
  50. # to be called after import.
  51. module = sys.modules.get(name, None)
  52. if module is None:
  53. _post_import_hooks.setdefault(name, {}).update({hook_id: hook})
  54. # If the module is already imported, we fire the hook right away. Note that
  55. # the hook is called outside of the lock to avoid deadlocks if code run as a
  56. # consequence of calling the module import hook in turn triggers a separate
  57. # thread which tries to register an import hook.
  58. if module is not None:
  59. hook(module)
  60. def unregister_post_import_hook(name: str, hook_id: str | None) -> None:
  61. # Remove the import hook if it has been registered.
  62. with _post_import_hooks_lock:
  63. hooks = _post_import_hooks.get(name)
  64. if hooks is not None:
  65. if hook_id is not None:
  66. hooks.pop(hook_id, None)
  67. if not hooks:
  68. del _post_import_hooks[name]
  69. else:
  70. del _post_import_hooks[name]
  71. def unregister_all_post_import_hooks() -> None:
  72. with _post_import_hooks_lock:
  73. _post_import_hooks.clear()
  74. # Indicate that a module has been loaded. Any post import hooks which
  75. # were registered against the target module will be invoked. If an
  76. # exception is raised in any of the post import hooks, that will cause
  77. # the import of the target module to fail.
  78. def notify_module_loaded(module: Any) -> None:
  79. name = getattr(module, "__name__", None)
  80. with _post_import_hooks_lock:
  81. hooks = _post_import_hooks.pop(name, {})
  82. # Note that the hook is called outside of the lock to avoid deadlocks if
  83. # code run as a consequence of calling the module import hook in turn
  84. # triggers a separate thread which tries to register an import hook.
  85. for hook in hooks.values():
  86. if hook:
  87. hook(module)
  88. # A custom module import finder. This intercepts attempts to import
  89. # modules and watches out for attempts to import target modules of
  90. # interest. When a module of interest is imported, then any post import
  91. # hooks which are registered will be invoked.
  92. class _ImportHookChainedLoader:
  93. def __init__(self, loader: Any) -> None:
  94. self.loader = loader
  95. if hasattr(loader, "load_module"):
  96. self.load_module = self._load_module
  97. if hasattr(loader, "create_module"):
  98. self.create_module = self._create_module
  99. if hasattr(loader, "exec_module"):
  100. self.exec_module = self._exec_module
  101. def _set_loader(self, module: Any) -> None:
  102. # Set module's loader to self.loader unless it's already set to
  103. # something else. Import machinery will set it to spec.loader if it is
  104. # None, so handle None as well. The module may not support attribute
  105. # assignment, in which case we simply skip it. Note that we also deal
  106. # with __loader__ not existing at all. This is to future proof things
  107. # due to proposal to remove the attribute as described in the GitHub
  108. # issue at https://github.com/python/cpython/issues/77458. Also prior
  109. # to Python 3.3, the __loader__ attribute was only set if a custom
  110. # module loader was used. It isn't clear whether the attribute still
  111. # existed in that case or was set to None.
  112. class UNDEFINED:
  113. pass
  114. if getattr(module, "__loader__", UNDEFINED) in (None, self):
  115. try:
  116. module.__loader__ = self.loader
  117. except AttributeError:
  118. pass
  119. if (
  120. getattr(module, "__spec__", None) is not None
  121. and getattr(module.__spec__, "loader", None) is self
  122. ):
  123. module.__spec__.loader = self.loader
  124. def _load_module(self, fullname: str) -> Any:
  125. module = self.loader.load_module(fullname)
  126. self._set_loader(module)
  127. notify_module_loaded(module)
  128. return module
  129. # Python 3.4 introduced create_module() and exec_module() instead of
  130. # load_module() alone. Splitting the two steps.
  131. def _create_module(self, spec: Any) -> Any:
  132. return self.loader.create_module(spec)
  133. def _exec_module(self, module: Any) -> None:
  134. self._set_loader(module)
  135. self.loader.exec_module(module)
  136. notify_module_loaded(module)
  137. class ImportHookFinder:
  138. def __init__(self) -> None:
  139. self.in_progress: dict = {}
  140. def find_module( # type: ignore
  141. self,
  142. fullname: str,
  143. path: str | None = None,
  144. ) -> _ImportHookChainedLoader | None:
  145. # If the module being imported is not one we have registered
  146. # post import hooks for, we can return immediately. We will
  147. # take no further part in the importing of this module.
  148. with _post_import_hooks_lock:
  149. if fullname not in _post_import_hooks:
  150. return None
  151. # When we are interested in a specific module, we will call back
  152. # into the import system a second time to defer to the import
  153. # finder that is supposed to handle the importing of the module.
  154. # We set an in progress flag for the target module so that on
  155. # the second time through we don't trigger another call back
  156. # into the import system and cause a infinite loop.
  157. if fullname in self.in_progress:
  158. return None
  159. self.in_progress[fullname] = True
  160. # Now call back into the import system again.
  161. try:
  162. # For Python 3 we need to use find_spec().loader
  163. # from the importlib.util module. It doesn't actually
  164. # import the target module and only finds the
  165. # loader. If a loader is found, we need to return
  166. # our own loader which will then in turn call the
  167. # real loader to import the module and invoke the
  168. # post import hooks.
  169. loader = getattr(find_spec(fullname), "loader", None)
  170. if loader and not isinstance(loader, _ImportHookChainedLoader):
  171. return _ImportHookChainedLoader(loader)
  172. finally:
  173. del self.in_progress[fullname]
  174. def find_spec(
  175. self, fullname: str, path: str | None = None, target: Any = None
  176. ) -> Any:
  177. # Since Python 3.4, you are meant to implement find_spec() method
  178. # instead of find_module() and since Python 3.10 you get deprecation
  179. # warnings if you don't define find_spec().
  180. # If the module being imported is not one we have registered
  181. # post import hooks for, we can return immediately. We will
  182. # take no further part in the importing of this module.
  183. with _post_import_hooks_lock:
  184. if fullname not in _post_import_hooks:
  185. return None
  186. # When we are interested in a specific module, we will call back
  187. # into the import system a second time to defer to the import
  188. # finder that is supposed to handle the importing of the module.
  189. # We set an in progress flag for the target module so that on
  190. # the second time through we don't trigger another call back
  191. # into the import system and cause a infinite loop.
  192. if fullname in self.in_progress:
  193. return None
  194. self.in_progress[fullname] = True
  195. # Now call back into the import system again.
  196. try:
  197. # This should only be Python 3 so find_spec() should always
  198. # exist so don't need to check.
  199. spec = find_spec(fullname)
  200. loader = getattr(spec, "loader", None)
  201. if loader and not isinstance(loader, _ImportHookChainedLoader):
  202. assert spec is not None
  203. spec.loader = _ImportHookChainedLoader(loader) # type: ignore
  204. return spec
  205. finally:
  206. del self.in_progress[fullname]