Skip to content

Commit 0d4fb38

Browse files
effigiesdschult
andauthored
ENH: Add require argument to load() to accept version specifiers (#48)
* ENH: Implement load_requirement * RF: Rewrite load_requirement as argument to load, add have_module() function * TEST: Test load(..., require=...) keyword arg and have_module() func * ENH: Delay loading of less likely modules Using python -X importtime -c "import lazy_loader": Before ------ import time: self [us] | cumulative | imported package [...] import time: 131 | 22995 | lazy_loader After ----- import time: self [us] | cumulative | imported package [...] import time: 115 | 4248 | lazy_loader * RF: Split requirement check into function, prefer importlib.metadata * Remove have_module (out-of-scope) * DOC: Update docstring and README * DOC: Note discrepancy between distribution and import names * Update README.md Co-authored-by: Dan Schult <dschult@colgate.edu>
1 parent bf82b68 commit 0d4fb38

File tree

4 files changed

+145
-32
lines changed

4 files changed

+145
-32
lines changed

README.md

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,29 @@ discouraged._
115115

116116
You can ask `lazy.load` to raise import errors as soon as it is called:
117117

118-
```
118+
```python
119119
linalg = lazy.load('scipy.linalg', error_on_import=True)
120120
```
121+
122+
#### Optional requirements
123+
124+
One use for lazy loading is for loading optional dependencies, with
125+
`ImportErrors` only arising when optional functionality is accessed. If optional
126+
functionality depends on a specific version, a version requirement can
127+
be set:
128+
129+
```python
130+
np = lazy.load("numpy", require="numpy >=1.24")
131+
```
132+
133+
In this case, if `numpy` is installed, but the version is less than 1.24,
134+
the `np` module returned will raise an error on attribute access. Using
135+
this feature is not all-or-nothing: One module may rely on one version of
136+
numpy, while another module may not set any requirement.
137+
138+
_Note that the requirement must use the package [distribution name][] instead
139+
of the module [import name][]. For example, the `pyyaml` distribution provides
140+
the `yaml` module for import._
141+
142+
[distribution name]: https://packaging.python.org/en/latest/glossary/#term-Distribution-Package
143+
[import name]: https://packaging.python.org/en/latest/glossary/#term-Import-Package

lazy_loader/__init__.py

Lines changed: 94 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
import ast
88
import importlib
99
import importlib.util
10-
import inspect
1110
import os
1211
import sys
1312
import types
@@ -99,24 +98,25 @@ def __dir__():
9998

10099

101100
class 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+
214277
class _StubVisitor(ast.NodeVisitor):
215278
"""AST visitor to parse a stub file for submodules and submod_attrs."""
216279

lazy_loader/tests/test_lazy_loader.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import importlib
22
import sys
33
import types
4+
from unittest import mock
45

56
import pytest
67

@@ -149,3 +150,25 @@ def test_stub_loading_errors(tmp_path):
149150

150151
with pytest.raises(ValueError, match="Cannot load imports from non-existent stub"):
151152
lazy.attach_stub("name", "not a file")
153+
154+
155+
def test_require_kwarg():
156+
have_importlib_metadata = importlib.util.find_spec("importlib.metadata") is not None
157+
dot = "." if have_importlib_metadata else "_"
158+
# Test with a module that definitely exists, behavior hinges on requirement
159+
with mock.patch(f"importlib{dot}metadata.version") as version:
160+
version.return_value = "1.0.0"
161+
math = lazy.load("math", require="somepkg >= 2.0")
162+
assert isinstance(math, lazy.DelayedImportErrorModule)
163+
164+
math = lazy.load("math", require="somepkg >= 1.0")
165+
assert math.sin(math.pi) == pytest.approx(0, 1e-6)
166+
167+
# We can fail even after a successful import
168+
math = lazy.load("math", require="somepkg >= 2.0")
169+
assert isinstance(math, lazy.DelayedImportErrorModule)
170+
171+
# When a module can be loaded but the version can't be checked,
172+
# raise a ValueError
173+
with pytest.raises(ValueError):
174+
lazy.load("math", require="somepkg >= 1.0")

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ classifiers = [
2121
"Programming Language :: Python :: 3.12",
2222
]
2323
description = "Makes it easy to load subpackages and functions on demand."
24+
dependencies = [
25+
"packaging",
26+
"importlib_metadata; python_version < '3.8'",
27+
]
2428

2529
[project.optional-dependencies]
2630
test = ["pytest >= 7.4", "pytest-cov >= 4.1"]

0 commit comments

Comments
 (0)