1import importlib
2import importlib.util
3import inspect
4import os
5import sys
6import types
7
8__all__ = ["attach", "_lazy_import"]
9
10
11def attach(module_name, submodules=None, submod_attrs=None):
12 """Attach lazily loaded submodules, and functions or other attributes.
13
14 Typically, modules import submodules and attributes as follows::
15
16 import mysubmodule
17 import anothersubmodule
18
19 from .foo import someattr
20
21 The idea of this function is to replace the `__init__.py`
22 module's `__getattr__`, `__dir__`, and `__all__` attributes such that
23 all imports work exactly the way they normally would, except that the
24 actual import is delayed until the resulting module object is first used.
25
26 The typical way to call this function, replacing the above imports, is::
27
28 __getattr__, __lazy_dir__, __all__ = lazy.attach(
29 __name__, ["mysubmodule", "anothersubmodule"], {"foo": "someattr"}
30 )
31
32 This functionality requires Python 3.7 or higher.
33
34 Parameters
35 ----------
36 module_name : str
37 Typically use __name__.
38 submodules : set
39 List of submodules to lazily import.
40 submod_attrs : dict
41 Dictionary of submodule -> list of attributes / functions.
42 These attributes are imported as they are used.
43
44 Returns
45 -------
46 __getattr__, __dir__, __all__
47
48 """
49 if submod_attrs is None:
50 submod_attrs = {}
51
52 if submodules is None:
53 submodules = set()
54 else:
55 submodules = set(submodules)
56
57 attr_to_modules = {
58 attr: mod for mod, attrs in submod_attrs.items() for attr in attrs
59 }
60
61 __all__ = list(submodules | attr_to_modules.keys())
62
63 def __getattr__(name):
64 if name in submodules:
65 return importlib.import_module(f"{module_name}.{name}")
66 elif name in attr_to_modules:
67 submod = importlib.import_module(f"{module_name}.{attr_to_modules[name]}")
68 return getattr(submod, name)
69 else:
70 raise AttributeError(f"No {module_name} attribute {name}")
71
72 def __dir__():
73 return __all__
74
75 if os.environ.get("EAGER_IMPORT", ""):
76 for attr in set(attr_to_modules.keys()) | submodules:
77 __getattr__(attr)
78
79 return __getattr__, __dir__, list(__all__)
80
81
82class DelayedImportErrorModule(types.ModuleType):
83 def __init__(self, frame_data, *args, **kwargs):
84 self.__frame_data = frame_data
85 super().__init__(*args, **kwargs)
86
87 def __getattr__(self, x):
88 if x in ("__class__", "__file__", "__frame_data"):
89 super().__getattr__(x)
90 else:
91 fd = self.__frame_data
92 raise ModuleNotFoundError(
93 f"No module named '{fd['spec']}'\n\n"
94 "This error is lazily reported, having originally occurred in\n"
95 f" File {fd['filename']}, line {fd['lineno']}, in {fd['function']}\n\n"
96 f"----> {''.join(fd['code_context'] or '').strip()}"
97 )
98
99
100def _lazy_import(fullname):
101 """Return a lazily imported proxy for a module or library.
102
103 Warning
104 -------
105 Importing using this function can currently cause trouble
106 when the user tries to import from a subpackage of a module before
107 the package is fully imported. In particular, this idiom may not work:
108
109 np = lazy_import("numpy")
110 from numpy.lib import recfunctions
111
112 This is due to a difference in the way Python's LazyLoader handles
113 subpackage imports compared to the normal import process. Hopefully
114 we will get Python's LazyLoader to fix this, or find a workaround.
115 In the meantime, this is a potential problem.
116
117 The workaround is to import numpy before importing from the subpackage.
118
119 Notes
120 -----
121 We often see the following pattern::
122
123 def myfunc():
124 import scipy as sp
125 sp.argmin(...)
126 ....
127
128 This is to prevent a library, in this case `scipy`, from being
129 imported at function definition time, since that can be slow.
130
131 This function provides a proxy module that, upon access, imports
132 the actual module. So the idiom equivalent to the above example is::
133
134 sp = lazy.load("scipy")
135
136 def myfunc():
137 sp.argmin(...)
138 ....
139
140 The initial import time is fast because the actual import is delayed
141 until the first attribute is requested. The overall import time may
142 decrease as well for users that don't make use of large portions
143 of the library.
144
145 Parameters
146 ----------
147 fullname : str
148 The full name of the package or subpackage to import. For example::
149
150 sp = lazy.load("scipy") # import scipy as sp
151 spla = lazy.load("scipy.linalg") # import scipy.linalg as spla
152
153 Returns
154 -------
155 pm : importlib.util._LazyModule
156 Proxy module. Can be used like any regularly imported module.
157 Actual loading of the module occurs upon first attribute request.
158
159 """
160 try:
161 return sys.modules[fullname]
162 except:
163 pass
164
165 # Not previously loaded -- look it up
166 spec = importlib.util.find_spec(fullname)
167
168 if spec is None:
169 try:
170 parent = inspect.stack()[1]
171 frame_data = {
172 "spec": fullname,
173 "filename": parent.filename,
174 "lineno": parent.lineno,
175 "function": parent.function,
176 "code_context": parent.code_context,
177 }
178 return DelayedImportErrorModule(frame_data, "DelayedImportErrorModule")
179 finally:
180 del parent
181
182 module = importlib.util.module_from_spec(spec)
183 sys.modules[fullname] = module
184
185 loader = importlib.util.LazyLoader(spec.loader)
186 loader.exec_module(module)
187
188 return module