Skip to content

Commit a04dbe4

Browse files
committed
Issue #17621: Introduce importlib.util.LazyLoader.
1 parent f22b2f0 commit a04dbe4

File tree

5 files changed

+266
-1
lines changed

5 files changed

+266
-1
lines changed

Doc/library/importlib.rst

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1191,3 +1191,38 @@ an :term:`importer`.
11911191
module will be file-based.
11921192

11931193
.. versionadded:: 3.4
1194+
1195+
.. class:: LazyLoader(loader)
1196+
1197+
A class which postpones the execution of the loader of a module until the
1198+
module has an attribute accessed.
1199+
1200+
This class **only** works with loaders that define
1201+
:meth:`importlib.abc.Loader.exec_module` as control over what module type
1202+
is used for the module is required. For the same reasons, the loader
1203+
**cannot** define :meth:`importlib.abc.Loader.create_module`. Finally,
1204+
modules which substitute the object placed into :attr:`sys.modules` will
1205+
not work as there is no way to properly replace the module references
1206+
throughout the interpreter safely; :exc:`ValueError` is raised if such a
1207+
substitution is detected.
1208+
1209+
.. note::
1210+
For projects where startup time is critical, this class allows for
1211+
potentially minimizing the cost of loading a module if it is never used.
1212+
For projects where startup time is not essential then use of this class is
1213+
**heavily** discouraged due to error messages created during loading being
1214+
postponed and thus occurring out of context.
1215+
1216+
.. versionadded:: 3.5
1217+
1218+
.. classmethod:: factory(loader)
1219+
1220+
A static method which returns a callable that creates a lazy loader. This
1221+
is meant to be used in situations where the loader is passed by class
1222+
instead of by instance.
1223+
::
1224+
1225+
suffixes = importlib.machinery.SOURCE_SUFFIXES
1226+
loader = importlib.machinery.SourceFileLoader
1227+
lazy_loader = importlib.util.LazyLoader.factory(loader)
1228+
finder = importlib.machinery.FileFinder(path, [(lazy_loader, suffixes)])

Doc/whatsnew/3.5.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,10 @@ Improved Modules
149149
subclassing of :class:`~inspect.Signature` easier (contributed
150150
by Yury Selivanov and Eric Snow in :issue:`17373`).
151151

152+
* :class:`importlib.util.LazyLoader` allows for the lazy loading of modules in
153+
applications where startup time is paramount (contributed by Brett Cannon in
154+
:issue:`17621`).
155+
152156

153157
Optimizations
154158
=============

Lib/importlib/util.py

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
"""Utility code for constructing importers, etc."""
2-
2+
from . import abc
33
from ._bootstrap import MAGIC_NUMBER
44
from ._bootstrap import cache_from_source
55
from ._bootstrap import decode_source
@@ -12,6 +12,7 @@
1212
from contextlib import contextmanager
1313
import functools
1414
import sys
15+
import types
1516
import warnings
1617

1718

@@ -200,3 +201,94 @@ def module_for_loader_wrapper(self, fullname, *args, **kwargs):
200201
return fxn(self, module, *args, **kwargs)
201202

202203
return module_for_loader_wrapper
204+
205+
206+
class _Module(types.ModuleType):
207+
208+
"""A subclass of the module type to allow __class__ manipulation."""
209+
210+
211+
class _LazyModule(types.ModuleType):
212+
213+
"""A subclass of the module type which triggers loading upon attribute access."""
214+
215+
def __getattribute__(self, attr):
216+
"""Trigger the load of the module and return the attribute."""
217+
# All module metadata must be garnered from __spec__ in order to avoid
218+
# using mutated values.
219+
# Stop triggering this method.
220+
self.__class__ = _Module
221+
# Get the original name to make sure no object substitution occurred
222+
# in sys.modules.
223+
original_name = self.__spec__.name
224+
# Figure out exactly what attributes were mutated between the creation
225+
# of the module and now.
226+
attrs_then = self.__spec__.loader_state
227+
attrs_now = self.__dict__
228+
attrs_updated = {}
229+
for key, value in attrs_now.items():
230+
# Code that set the attribute may have kept a reference to the
231+
# assigned object, making identity more important than equality.
232+
if key not in attrs_then:
233+
attrs_updated[key] = value
234+
elif id(attrs_now[key]) != id(attrs_then[key]):
235+
attrs_updated[key] = value
236+
self.__spec__.loader.exec_module(self)
237+
# If exec_module() was used directly there is no guarantee the module
238+
# object was put into sys.modules.
239+
if original_name in sys.modules:
240+
if id(self) != id(sys.modules[original_name]):
241+
msg = ('module object for {!r} substituted in sys.modules '
242+
'during a lazy load')
243+
raise ValueError(msg.format(original_name))
244+
# Update after loading since that's what would happen in an eager
245+
# loading situation.
246+
self.__dict__.update(attrs_updated)
247+
return getattr(self, attr)
248+
249+
def __delattr__(self, attr):
250+
"""Trigger the load and then perform the deletion."""
251+
# To trigger the load and raise an exception if the attribute
252+
# doesn't exist.
253+
self.__getattribute__(attr)
254+
delattr(self, attr)
255+
256+
257+
class LazyLoader(abc.Loader):
258+
259+
"""A loader that creates a module which defers loading until attribute access."""
260+
261+
@staticmethod
262+
def __check_eager_loader(loader):
263+
if not hasattr(loader, 'exec_module'):
264+
raise TypeError('loader must define exec_module()')
265+
elif hasattr(loader.__class__, 'create_module'):
266+
if abc.Loader.create_module != loader.__class__.create_module:
267+
# Only care if create_module() is overridden in a subclass of
268+
# importlib.abc.Loader.
269+
raise TypeError('loader cannot define create_module()')
270+
271+
@classmethod
272+
def factory(cls, loader):
273+
"""Construct a callable which returns the eager loader made lazy."""
274+
cls.__check_eager_loader(loader)
275+
return lambda *args, **kwargs: cls(loader(*args, **kwargs))
276+
277+
def __init__(self, loader):
278+
self.__check_eager_loader(loader)
279+
self.loader = loader
280+
281+
def create_module(self, spec):
282+
"""Create a module which can have its __class__ manipulated."""
283+
return _Module(spec.name)
284+
285+
def exec_module(self, module):
286+
"""Make the module load lazily."""
287+
module.__spec__.loader = self.loader
288+
module.__loader__ = self.loader
289+
# Don't need to worry about deep-copying as trying to set an attribute
290+
# on an object would have triggered the load,
291+
# e.g. ``module.__spec__.loader = None`` would trigger a load from
292+
# trying to access module.__spec__.
293+
module.__spec__.loader_state = module.__dict__.copy()
294+
module.__class__ = _LazyModule
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import importlib
2+
from importlib import abc
3+
from importlib import util
4+
import unittest
5+
6+
from . import util as test_util
7+
8+
9+
class CollectInit:
10+
11+
def __init__(self, *args, **kwargs):
12+
self.args = args
13+
self.kwargs = kwargs
14+
15+
def exec_module(self, module):
16+
return self
17+
18+
19+
class LazyLoaderFactoryTests(unittest.TestCase):
20+
21+
def test_init(self):
22+
factory = util.LazyLoader.factory(CollectInit)
23+
# E.g. what importlib.machinery.FileFinder instantiates loaders with
24+
# plus keyword arguments.
25+
lazy_loader = factory('module name', 'module path', kw='kw')
26+
loader = lazy_loader.loader
27+
self.assertEqual(('module name', 'module path'), loader.args)
28+
self.assertEqual({'kw': 'kw'}, loader.kwargs)
29+
30+
def test_validation(self):
31+
# No exec_module(), no lazy loading.
32+
with self.assertRaises(TypeError):
33+
util.LazyLoader.factory(object)
34+
35+
36+
class TestingImporter(abc.MetaPathFinder, abc.Loader):
37+
38+
module_name = 'lazy_loader_test'
39+
mutated_name = 'changed'
40+
loaded = None
41+
source_code = 'attr = 42; __name__ = {!r}'.format(mutated_name)
42+
43+
def find_spec(self, name, path, target=None):
44+
if name != self.module_name:
45+
return None
46+
return util.spec_from_loader(name, util.LazyLoader(self))
47+
48+
def exec_module(self, module):
49+
exec(self.source_code, module.__dict__)
50+
self.loaded = module
51+
52+
53+
class LazyLoaderTests(unittest.TestCase):
54+
55+
def test_init(self):
56+
with self.assertRaises(TypeError):
57+
util.LazyLoader(object)
58+
59+
def new_module(self, source_code=None):
60+
loader = TestingImporter()
61+
if source_code is not None:
62+
loader.source_code = source_code
63+
spec = util.spec_from_loader(TestingImporter.module_name,
64+
util.LazyLoader(loader))
65+
module = spec.loader.create_module(spec)
66+
module.__spec__ = spec
67+
module.__loader__ = spec.loader
68+
spec.loader.exec_module(module)
69+
# Module is now lazy.
70+
self.assertIsNone(loader.loaded)
71+
return module
72+
73+
def test_e2e(self):
74+
# End-to-end test to verify the load is in fact lazy.
75+
importer = TestingImporter()
76+
assert importer.loaded is None
77+
with test_util.uncache(importer.module_name):
78+
with test_util.import_state(meta_path=[importer]):
79+
module = importlib.import_module(importer.module_name)
80+
self.assertIsNone(importer.loaded)
81+
# Trigger load.
82+
self.assertEqual(module.__loader__, importer)
83+
self.assertIsNotNone(importer.loaded)
84+
self.assertEqual(module, importer.loaded)
85+
86+
def test_attr_unchanged(self):
87+
# An attribute only mutated as a side-effect of import should not be
88+
# changed needlessly.
89+
module = self.new_module()
90+
self.assertEqual(TestingImporter.mutated_name, module.__name__)
91+
92+
def test_new_attr(self):
93+
# A new attribute should persist.
94+
module = self.new_module()
95+
module.new_attr = 42
96+
self.assertEqual(42, module.new_attr)
97+
98+
def test_mutated_preexisting_attr(self):
99+
# Changing an attribute that already existed on the module --
100+
# e.g. __name__ -- should persist.
101+
module = self.new_module()
102+
module.__name__ = 'bogus'
103+
self.assertEqual('bogus', module.__name__)
104+
105+
def test_mutated_attr(self):
106+
# Changing an attribute that comes into existence after an import
107+
# should persist.
108+
module = self.new_module()
109+
module.attr = 6
110+
self.assertEqual(6, module.attr)
111+
112+
def test_delete_eventual_attr(self):
113+
# Deleting an attribute should stay deleted.
114+
module = self.new_module()
115+
del module.attr
116+
self.assertFalse(hasattr(module, 'attr'))
117+
118+
def test_delete_preexisting_attr(self):
119+
module = self.new_module()
120+
del module.__name__
121+
self.assertFalse(hasattr(module, '__name__'))
122+
123+
def test_module_substitution_error(self):
124+
source_code = 'import sys; sys.modules[__name__] = 42'
125+
module = self.new_module(source_code)
126+
with test_util.uncache(TestingImporter.module_name):
127+
with self.assertRaises(ValueError):
128+
module.__name__
129+
130+
131+
if __name__ == '__main__':
132+
unittest.main()

Misc/NEWS

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ Core and Builtins
2929
Library
3030
-------
3131

32+
- Issue #17621: Introduce importlib.util.LazyLoader.
33+
3234
- Issue #21076: signal module constants were turned into enums.
3335
Patch by Giampaolo Rodola'.
3436

0 commit comments

Comments
 (0)