Cruicis commited on
Commit
6d486bf
·
verified ·
1 Parent(s): 679a1a2

Upload 13 files

Browse files
lazy_loader/__init__.py ADDED
@@ -0,0 +1,349 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ lazy_loader
3
+ ===========
4
+
5
+ Makes it easy to load subpackages and functions on demand.
6
+ """
7
+
8
+ import ast
9
+ import importlib
10
+ import importlib.util
11
+ import os
12
+ import sys
13
+ import threading
14
+ import types
15
+ import warnings
16
+
17
+ __version__ = "0.4"
18
+ __all__ = ["attach", "load", "attach_stub"]
19
+
20
+
21
+ threadlock = threading.Lock()
22
+
23
+
24
+ def attach(package_name, submodules=None, submod_attrs=None):
25
+ """Attach lazily loaded submodules, functions, or other attributes.
26
+
27
+ Typically, modules import submodules and attributes as follows::
28
+
29
+ import mysubmodule
30
+ import anothersubmodule
31
+
32
+ from .foo import someattr
33
+
34
+ The idea is to replace a package's `__getattr__`, `__dir__`, and
35
+ `__all__`, such that all imports work exactly the way they would
36
+ with normal imports, except that the import occurs upon first use.
37
+
38
+ The typical way to call this function, replacing the above imports, is::
39
+
40
+ __getattr__, __dir__, __all__ = lazy.attach(
41
+ __name__,
42
+ ['mysubmodule', 'anothersubmodule'],
43
+ {'foo': ['someattr']}
44
+ )
45
+
46
+ This functionality requires Python 3.7 or higher.
47
+
48
+ Parameters
49
+ ----------
50
+ package_name : str
51
+ Typically use ``__name__``.
52
+ submodules : set
53
+ List of submodules to attach.
54
+ submod_attrs : dict
55
+ Dictionary of submodule -> list of attributes / functions.
56
+ These attributes are imported as they are used.
57
+
58
+ Returns
59
+ -------
60
+ __getattr__, __dir__, __all__
61
+
62
+ """
63
+ if submod_attrs is None:
64
+ submod_attrs = {}
65
+
66
+ if submodules is None:
67
+ submodules = set()
68
+ else:
69
+ submodules = set(submodules)
70
+
71
+ attr_to_modules = {
72
+ attr: mod for mod, attrs in submod_attrs.items() for attr in attrs
73
+ }
74
+
75
+ __all__ = sorted(submodules | attr_to_modules.keys())
76
+
77
+ def __getattr__(name):
78
+ if name in submodules:
79
+ return importlib.import_module(f"{package_name}.{name}")
80
+ elif name in attr_to_modules:
81
+ submod_path = f"{package_name}.{attr_to_modules[name]}"
82
+ submod = importlib.import_module(submod_path)
83
+ attr = getattr(submod, name)
84
+
85
+ # If the attribute lives in a file (module) with the same
86
+ # name as the attribute, ensure that the attribute and *not*
87
+ # the module is accessible on the package.
88
+ if name == attr_to_modules[name]:
89
+ pkg = sys.modules[package_name]
90
+ pkg.__dict__[name] = attr
91
+
92
+ return attr
93
+ else:
94
+ raise AttributeError(f"No {package_name} attribute {name}")
95
+
96
+ def __dir__():
97
+ return __all__
98
+
99
+ if os.environ.get("EAGER_IMPORT", ""):
100
+ for attr in set(attr_to_modules.keys()) | submodules:
101
+ __getattr__(attr)
102
+
103
+ return __getattr__, __dir__, list(__all__)
104
+
105
+
106
+ class DelayedImportErrorModule(types.ModuleType):
107
+ def __init__(self, frame_data, *args, message, **kwargs):
108
+ self.__frame_data = frame_data
109
+ self.__message = message
110
+ super().__init__(*args, **kwargs)
111
+
112
+ def __getattr__(self, x):
113
+ if x in ("__class__", "__file__", "__frame_data", "__message"):
114
+ super().__getattr__(x)
115
+ else:
116
+ fd = self.__frame_data
117
+ raise ModuleNotFoundError(
118
+ f"{self.__message}\n\n"
119
+ "This error is lazily reported, having originally occured in\n"
120
+ f' File {fd["filename"]}, line {fd["lineno"]}, in {fd["function"]}\n\n'
121
+ f'----> {"".join(fd["code_context"] or "").strip()}'
122
+ )
123
+
124
+
125
+ def load(fullname, *, require=None, error_on_import=False):
126
+ """Return a lazily imported proxy for a module.
127
+
128
+ We often see the following pattern::
129
+
130
+ def myfunc():
131
+ import numpy as np
132
+ np.norm(...)
133
+ ....
134
+
135
+ Putting the import inside the function prevents, in this case,
136
+ `numpy`, from being imported at function definition time.
137
+ That saves time if `myfunc` ends up not being called.
138
+
139
+ This `load` function returns a proxy module that, upon access, imports
140
+ the actual module. So the idiom equivalent to the above example is::
141
+
142
+ np = lazy.load("numpy")
143
+
144
+ def myfunc():
145
+ np.norm(...)
146
+ ....
147
+
148
+ The initial import time is fast because the actual import is delayed
149
+ until the first attribute is requested. The overall import time may
150
+ decrease as well for users that don't make use of large portions
151
+ of your library.
152
+
153
+ Warning
154
+ -------
155
+ While lazily loading *sub*packages technically works, it causes the
156
+ package (that contains the subpackage) to be eagerly loaded even
157
+ if the package is already lazily loaded.
158
+ So, you probably shouldn't use subpackages with this `load` feature.
159
+ Instead you should encourage the package maintainers to use the
160
+ `lazy_loader.attach` to make their subpackages load lazily.
161
+
162
+ Parameters
163
+ ----------
164
+ fullname : str
165
+ The full name of the module or submodule to import. For example::
166
+
167
+ sp = lazy.load('scipy') # import scipy as sp
168
+
169
+ require : str
170
+ A dependency requirement as defined in PEP-508. For example::
171
+
172
+ "numpy >=1.24"
173
+
174
+ If defined, the proxy module will raise an error if the installed
175
+ version does not satisfy the requirement.
176
+
177
+ error_on_import : bool
178
+ Whether to postpone raising import errors until the module is accessed.
179
+ If set to `True`, import errors are raised as soon as `load` is called.
180
+
181
+ Returns
182
+ -------
183
+ pm : importlib.util._LazyModule
184
+ Proxy module. Can be used like any regularly imported module.
185
+ Actual loading of the module occurs upon first attribute request.
186
+
187
+ """
188
+ with threadlock:
189
+ module = sys.modules.get(fullname)
190
+ have_module = module is not None
191
+
192
+ # Most common, short-circuit
193
+ if have_module and require is None:
194
+ return module
195
+
196
+ if "." in fullname:
197
+ msg = (
198
+ "subpackages can technically be lazily loaded, but it causes the "
199
+ "package to be eagerly loaded even if it is already lazily loaded."
200
+ "So, you probably shouldn't use subpackages with this lazy feature."
201
+ )
202
+ warnings.warn(msg, RuntimeWarning)
203
+
204
+ spec = None
205
+
206
+ if not have_module:
207
+ spec = importlib.util.find_spec(fullname)
208
+ have_module = spec is not None
209
+
210
+ if not have_module:
211
+ not_found_message = f"No module named '{fullname}'"
212
+ elif require is not None:
213
+ try:
214
+ have_module = _check_requirement(require)
215
+ except ModuleNotFoundError as e:
216
+ raise ValueError(
217
+ f"Found module '{fullname}' but cannot test "
218
+ "requirement '{require}'. "
219
+ "Requirements must match distribution name, not module name."
220
+ ) from e
221
+
222
+ not_found_message = f"No distribution can be found matching '{require}'"
223
+
224
+ if not have_module:
225
+ if error_on_import:
226
+ raise ModuleNotFoundError(not_found_message)
227
+ import inspect
228
+
229
+ try:
230
+ parent = inspect.stack()[1]
231
+ frame_data = {
232
+ "filename": parent.filename,
233
+ "lineno": parent.lineno,
234
+ "function": parent.function,
235
+ "code_context": parent.code_context,
236
+ }
237
+ return DelayedImportErrorModule(
238
+ frame_data,
239
+ "DelayedImportErrorModule",
240
+ message=not_found_message,
241
+ )
242
+ finally:
243
+ del parent
244
+
245
+ if spec is not None:
246
+ module = importlib.util.module_from_spec(spec)
247
+ sys.modules[fullname] = module
248
+
249
+ loader = importlib.util.LazyLoader(spec.loader)
250
+ loader.exec_module(module)
251
+
252
+ return module
253
+
254
+
255
+ def _check_requirement(require: str) -> bool:
256
+ """Verify that a package requirement is satisfied
257
+
258
+ If the package is required, a ``ModuleNotFoundError`` is raised
259
+ by ``importlib.metadata``.
260
+
261
+ Parameters
262
+ ----------
263
+ require : str
264
+ A dependency requirement as defined in PEP-508
265
+
266
+ Returns
267
+ -------
268
+ satisfied : bool
269
+ True if the installed version of the dependency matches
270
+ the specified version, False otherwise.
271
+ """
272
+ import packaging.requirements
273
+
274
+ try:
275
+ import importlib.metadata as importlib_metadata
276
+ except ImportError: # PY37
277
+ import importlib_metadata
278
+
279
+ req = packaging.requirements.Requirement(require)
280
+ return req.specifier.contains(
281
+ importlib_metadata.version(req.name),
282
+ prereleases=True,
283
+ )
284
+
285
+
286
+ class _StubVisitor(ast.NodeVisitor):
287
+ """AST visitor to parse a stub file for submodules and submod_attrs."""
288
+
289
+ def __init__(self):
290
+ self._submodules = set()
291
+ self._submod_attrs = {}
292
+
293
+ def visit_ImportFrom(self, node: ast.ImportFrom):
294
+ if node.level != 1:
295
+ raise ValueError(
296
+ "Only within-module imports are supported (`from .* import`)"
297
+ )
298
+ if node.module:
299
+ attrs: list = self._submod_attrs.setdefault(node.module, [])
300
+ aliases = [alias.name for alias in node.names]
301
+ if "*" in aliases:
302
+ raise ValueError(
303
+ "lazy stub loader does not support star import "
304
+ f"`from {node.module} import *`"
305
+ )
306
+ attrs.extend(aliases)
307
+ else:
308
+ self._submodules.update(alias.name for alias in node.names)
309
+
310
+
311
+ def attach_stub(package_name: str, filename: str):
312
+ """Attach lazily loaded submodules, functions from a type stub.
313
+
314
+ This is a variant on ``attach`` that will parse a `.pyi` stub file to
315
+ infer ``submodules`` and ``submod_attrs``. This allows static type checkers
316
+ to find imports, while still providing lazy loading at runtime.
317
+
318
+ Parameters
319
+ ----------
320
+ package_name : str
321
+ Typically use ``__name__``.
322
+ filename : str
323
+ Path to `.py` file which has an adjacent `.pyi` file.
324
+ Typically use ``__file__``.
325
+
326
+ Returns
327
+ -------
328
+ __getattr__, __dir__, __all__
329
+ The same output as ``attach``.
330
+
331
+ Raises
332
+ ------
333
+ ValueError
334
+ If a stub file is not found for `filename`, or if the stubfile is formmated
335
+ incorrectly (e.g. if it contains an relative import from outside of the module)
336
+ """
337
+ stubfile = (
338
+ filename if filename.endswith("i") else f"{os.path.splitext(filename)[0]}.pyi"
339
+ )
340
+
341
+ if not os.path.exists(stubfile):
342
+ raise ValueError(f"Cannot load imports from non-existent stub {stubfile!r}")
343
+
344
+ with open(stubfile) as f:
345
+ stub_node = ast.parse(f.read())
346
+
347
+ visitor = _StubVisitor()
348
+ visitor.visit(stub_node)
349
+ return attach(package_name, visitor._submodules, visitor._submod_attrs)
lazy_loader/__pycache__/__init__.cpython-310.pyc ADDED
Binary file (10.5 kB). View file
 
lazy_loader/tests/__init__.py ADDED
File without changes
lazy_loader/tests/__pycache__/__init__.cpython-310.pyc ADDED
Binary file (155 Bytes). View file
 
lazy_loader/tests/__pycache__/import_np_parallel.cpython-310.pyc ADDED
Binary file (469 Bytes). View file
 
lazy_loader/tests/__pycache__/test_lazy_loader.cpython-310.pyc ADDED
Binary file (5.15 kB). View file
 
lazy_loader/tests/fake_pkg/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ import lazy_loader as lazy
2
+
3
+ __getattr__, __lazy_dir__, __all__ = lazy.attach(
4
+ __name__, submod_attrs={"some_func": ["some_func"]}
5
+ )
lazy_loader/tests/fake_pkg/__init__.pyi ADDED
@@ -0,0 +1 @@
 
 
1
+ from .some_func import some_func
lazy_loader/tests/fake_pkg/__pycache__/__init__.cpython-310.pyc ADDED
Binary file (306 Bytes). View file
 
lazy_loader/tests/fake_pkg/__pycache__/some_func.cpython-310.pyc ADDED
Binary file (303 Bytes). View file
 
lazy_loader/tests/fake_pkg/some_func.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ def some_func():
2
+ """Function with same name as submodule."""
3
+ pass
lazy_loader/tests/import_np_parallel.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import threading
2
+ import time
3
+
4
+ import lazy_loader as lazy
5
+
6
+
7
+ def import_np():
8
+ time.sleep(0.5)
9
+ lazy.load("numpy")
10
+
11
+
12
+ for _ in range(10):
13
+ threading.Thread(target=import_np).start()
lazy_loader/tests/test_lazy_loader.py ADDED
@@ -0,0 +1,192 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import importlib
2
+ import os
3
+ import subprocess
4
+ import sys
5
+ import types
6
+ from unittest import mock
7
+
8
+ import pytest
9
+
10
+ import lazy_loader as lazy
11
+
12
+
13
+ def test_lazy_import_basics():
14
+ math = lazy.load("math")
15
+ anything_not_real = lazy.load("anything_not_real")
16
+
17
+ # Now test that accessing attributes does what it should
18
+ assert math.sin(math.pi) == pytest.approx(0, 1e-6)
19
+ # poor-mans pytest.raises for testing errors on attribute access
20
+ try:
21
+ anything_not_real.pi
22
+ raise AssertionError() # Should not get here
23
+ except ModuleNotFoundError:
24
+ pass
25
+ assert isinstance(anything_not_real, lazy.DelayedImportErrorModule)
26
+ # see if it changes for second access
27
+ try:
28
+ anything_not_real.pi
29
+ raise AssertionError() # Should not get here
30
+ except ModuleNotFoundError:
31
+ pass
32
+
33
+
34
+ def test_lazy_import_subpackages():
35
+ with pytest.warns(RuntimeWarning):
36
+ hp = lazy.load("html.parser")
37
+ assert "html" in sys.modules
38
+ assert type(sys.modules["html"]) == type(pytest)
39
+ assert isinstance(hp, importlib.util._LazyModule)
40
+ assert "html.parser" in sys.modules
41
+ assert sys.modules["html.parser"] == hp
42
+
43
+
44
+ def test_lazy_import_impact_on_sys_modules():
45
+ math = lazy.load("math")
46
+ anything_not_real = lazy.load("anything_not_real")
47
+
48
+ assert isinstance(math, types.ModuleType)
49
+ assert "math" in sys.modules
50
+ assert isinstance(anything_not_real, lazy.DelayedImportErrorModule)
51
+ assert "anything_not_real" not in sys.modules
52
+
53
+ # only do this if numpy is installed
54
+ pytest.importorskip("numpy")
55
+ np = lazy.load("numpy")
56
+ assert isinstance(np, types.ModuleType)
57
+ assert "numpy" in sys.modules
58
+
59
+ np.pi # trigger load of numpy
60
+
61
+ assert isinstance(np, types.ModuleType)
62
+ assert "numpy" in sys.modules
63
+
64
+
65
+ def test_lazy_import_nonbuiltins():
66
+ np = lazy.load("numpy")
67
+ sp = lazy.load("scipy")
68
+ if not isinstance(np, lazy.DelayedImportErrorModule):
69
+ assert np.sin(np.pi) == pytest.approx(0, 1e-6)
70
+ if isinstance(sp, lazy.DelayedImportErrorModule):
71
+ try:
72
+ sp.pi
73
+ raise AssertionError()
74
+ except ModuleNotFoundError:
75
+ pass
76
+
77
+
78
+ def test_lazy_attach():
79
+ name = "mymod"
80
+ submods = ["mysubmodule", "anothersubmodule"]
81
+ myall = {"not_real_submod": ["some_var_or_func"]}
82
+
83
+ locls = {
84
+ "attach": lazy.attach,
85
+ "name": name,
86
+ "submods": submods,
87
+ "myall": myall,
88
+ }
89
+ s = "__getattr__, __lazy_dir__, __all__ = attach(name, submods, myall)"
90
+
91
+ exec(s, {}, locls)
92
+ expected = {
93
+ "attach": lazy.attach,
94
+ "name": name,
95
+ "submods": submods,
96
+ "myall": myall,
97
+ "__getattr__": None,
98
+ "__lazy_dir__": None,
99
+ "__all__": None,
100
+ }
101
+ assert locls.keys() == expected.keys()
102
+ for k, v in expected.items():
103
+ if v is not None:
104
+ assert locls[k] == v
105
+
106
+
107
+ def test_attach_same_module_and_attr_name():
108
+ from lazy_loader.tests import fake_pkg
109
+
110
+ # Grab attribute twice, to ensure that importing it does not
111
+ # override function by module
112
+ assert isinstance(fake_pkg.some_func, types.FunctionType)
113
+ assert isinstance(fake_pkg.some_func, types.FunctionType)
114
+
115
+ # Ensure imports from submodule still work
116
+ from lazy_loader.tests.fake_pkg.some_func import some_func
117
+
118
+ assert isinstance(some_func, types.FunctionType)
119
+
120
+
121
+ FAKE_STUB = """
122
+ from . import rank
123
+ from ._gaussian import gaussian
124
+ from .edges import sobel, scharr, prewitt, roberts
125
+ """
126
+
127
+
128
+ def test_stub_loading(tmp_path):
129
+ stub = tmp_path / "stub.pyi"
130
+ stub.write_text(FAKE_STUB)
131
+ _get, _dir, _all = lazy.attach_stub("my_module", str(stub))
132
+ expect = {"gaussian", "sobel", "scharr", "prewitt", "roberts", "rank"}
133
+ assert set(_dir()) == set(_all) == expect
134
+
135
+
136
+ def test_stub_loading_parity():
137
+ from lazy_loader.tests import fake_pkg
138
+
139
+ from_stub = lazy.attach_stub(fake_pkg.__name__, fake_pkg.__file__)
140
+ stub_getter, stub_dir, stub_all = from_stub
141
+ assert stub_all == fake_pkg.__all__
142
+ assert stub_dir() == fake_pkg.__lazy_dir__()
143
+ assert stub_getter("some_func") == fake_pkg.some_func
144
+
145
+
146
+ def test_stub_loading_errors(tmp_path):
147
+ stub = tmp_path / "stub.pyi"
148
+ stub.write_text("from ..mod import func\n")
149
+
150
+ with pytest.raises(ValueError, match="Only within-module imports are supported"):
151
+ lazy.attach_stub("name", str(stub))
152
+
153
+ with pytest.raises(ValueError, match="Cannot load imports from non-existent stub"):
154
+ lazy.attach_stub("name", "not a file")
155
+
156
+ stub2 = tmp_path / "stub2.pyi"
157
+ stub2.write_text("from .mod import *\n")
158
+ with pytest.raises(ValueError, match=".*does not support star import"):
159
+ lazy.attach_stub("name", str(stub2))
160
+
161
+
162
+ def test_require_kwarg():
163
+ have_importlib_metadata = importlib.util.find_spec("importlib.metadata") is not None
164
+ dot = "." if have_importlib_metadata else "_"
165
+ # Test with a module that definitely exists, behavior hinges on requirement
166
+ with mock.patch(f"importlib{dot}metadata.version") as version:
167
+ version.return_value = "1.0.0"
168
+ math = lazy.load("math", require="somepkg >= 2.0")
169
+ assert isinstance(math, lazy.DelayedImportErrorModule)
170
+
171
+ math = lazy.load("math", require="somepkg >= 1.0")
172
+ assert math.sin(math.pi) == pytest.approx(0, 1e-6)
173
+
174
+ # We can fail even after a successful import
175
+ math = lazy.load("math", require="somepkg >= 2.0")
176
+ assert isinstance(math, lazy.DelayedImportErrorModule)
177
+
178
+ # When a module can be loaded but the version can't be checked,
179
+ # raise a ValueError
180
+ with pytest.raises(ValueError):
181
+ lazy.load("math", require="somepkg >= 1.0")
182
+
183
+
184
+ def test_parallel_load():
185
+ pytest.importorskip("numpy")
186
+
187
+ subprocess.run(
188
+ [
189
+ sys.executable,
190
+ os.path.join(os.path.dirname(__file__), "import_np_parallel.py"),
191
+ ]
192
+ )