77import ast
88import importlib
99import importlib .util
10- import inspect
1110import os
1211import sys
1312import types
@@ -99,24 +98,25 @@ def __dir__():
9998
10099
101100class DelayedImportErrorModule (types .ModuleType ):
102- def __init__ (self , frame_data , * args , ** kwargs ):
101+ def __init__ (self , frame_data , * args , message , ** kwargs ):
103102 self .__frame_data = frame_data
103+ self .__message = message
104104 super ().__init__ (* args , ** kwargs )
105105
106106 def __getattr__ (self , x ):
107- if x in ("__class__" , "__file__" , "__frame_data" ):
107+ if x in ("__class__" , "__file__" , "__frame_data" , "__message" ):
108108 super ().__getattr__ (x )
109109 else :
110110 fd = self .__frame_data
111111 raise ModuleNotFoundError (
112- f"No module named ' { fd [ 'spec' ] } ' \n \n "
112+ f"{ self . __message } \n \n "
113113 "This error is lazily reported, having originally occured in\n "
114114 f' File { fd ["filename" ]} , line { fd ["lineno" ]} , in { fd ["function" ]} \n \n '
115115 f'----> { "" .join (fd ["code_context" ] or "" ).strip ()} '
116116 )
117117
118118
119- def load (fullname , error_on_import = False ):
119+ def load (fullname , * , require = None , error_on_import = False ):
120120 """Return a lazily imported proxy for a module.
121121
122122 We often see the following pattern::
@@ -160,6 +160,14 @@ def myfunc():
160160
161161 sp = lazy.load('scipy') # import scipy as sp
162162
163+ require : str
164+ A dependency requirement as defined in PEP-508. For example::
165+
166+ "numpy >=1.24"
167+
168+ If defined, the proxy module will raise an error if the installed
169+ version does not satisfy the requirement.
170+
163171 error_on_import : bool
164172 Whether to postpone raising import errors until the module is accessed.
165173 If set to `True`, import errors are raised as soon as `load` is called.
@@ -171,10 +179,12 @@ def myfunc():
171179 Actual loading of the module occurs upon first attribute request.
172180
173181 """
174- try :
175- return sys .modules [fullname ]
176- except KeyError :
177- pass
182+ module = sys .modules .get (fullname )
183+ have_module = module is not None
184+
185+ # Most common, short-circuit
186+ if have_module and require is None :
187+ return module
178188
179189 if "." in fullname :
180190 msg = (
@@ -184,33 +194,86 @@ def myfunc():
184194 )
185195 warnings .warn (msg , RuntimeWarning )
186196
187- spec = importlib .util .find_spec (fullname )
188- if spec is None :
197+ spec = None
198+ if not have_module :
199+ spec = importlib .util .find_spec (fullname )
200+ have_module = spec is not None
201+
202+ if not have_module :
203+ not_found_message = f"No module named '{ fullname } '"
204+ elif require is not None :
205+ try :
206+ have_module = _check_requirement (require )
207+ except ModuleNotFoundError as e :
208+ raise ValueError (
209+ f"Found module '{ fullname } ' but cannot test requirement '{ require } '. "
210+ "Requirements must match distribution name, not module name."
211+ ) from e
212+
213+ not_found_message = f"No distribution can be found matching '{ require } '"
214+
215+ if not have_module :
189216 if error_on_import :
190- raise ModuleNotFoundError (f"No module named '{ fullname } '" )
191- else :
192- try :
193- parent = inspect .stack ()[1 ]
194- frame_data = {
195- "spec" : fullname ,
196- "filename" : parent .filename ,
197- "lineno" : parent .lineno ,
198- "function" : parent .function ,
199- "code_context" : parent .code_context ,
200- }
201- return DelayedImportErrorModule (frame_data , "DelayedImportErrorModule" )
202- finally :
203- del parent
204-
205- module = importlib .util .module_from_spec (spec )
206- sys .modules [fullname ] = module
207-
208- loader = importlib .util .LazyLoader (spec .loader )
209- loader .exec_module (module )
217+ raise ModuleNotFoundError (not_found_message )
218+ import inspect
219+
220+ try :
221+ parent = inspect .stack ()[1 ]
222+ frame_data = {
223+ "filename" : parent .filename ,
224+ "lineno" : parent .lineno ,
225+ "function" : parent .function ,
226+ "code_context" : parent .code_context ,
227+ }
228+ return DelayedImportErrorModule (
229+ frame_data ,
230+ "DelayedImportErrorModule" ,
231+ message = not_found_message ,
232+ )
233+ finally :
234+ del parent
235+
236+ if spec is not None :
237+ module = importlib .util .module_from_spec (spec )
238+ sys .modules [fullname ] = module
239+
240+ loader = importlib .util .LazyLoader (spec .loader )
241+ loader .exec_module (module )
210242
211243 return module
212244
213245
246+ def _check_requirement (require : str ) -> bool :
247+ """Verify that a package requirement is satisfied
248+
249+ If the package is required, a ``ModuleNotFoundError`` is raised
250+ by ``importlib.metadata``.
251+
252+ Parameters
253+ ----------
254+ require : str
255+ A dependency requirement as defined in PEP-508
256+
257+ Returns
258+ -------
259+ satisfied : bool
260+ True if the installed version of the dependency matches
261+ the specified version, False otherwise.
262+ """
263+ import packaging .requirements
264+
265+ try :
266+ import importlib .metadata as importlib_metadata
267+ except ImportError : # PY37
268+ import importlib_metadata
269+
270+ req = packaging .requirements .Requirement (require )
271+ return req .specifier .contains (
272+ importlib_metadata .version (req .name ),
273+ prereleases = True ,
274+ )
275+
276+
214277class _StubVisitor (ast .NodeVisitor ):
215278 """AST visitor to parse a stub file for submodules and submod_attrs."""
216279
0 commit comments