A better solution has these properties:
- drop-in replacement for import statement. In other words: can be used for global or local imports, no extra checks at point of use, same syntax as regular module usage
- no messing with globals() dict
- can be used to import 3rd party libs, i.e. no code changes
- minimal performance overhead
Solution
Here's my solution with those properties. The concept is very simple:
class LazyLoader () :
'thin shell class to wrap modules. load real module on first access and pass thru'
def __init__ (me, modname) :
me._modname = modname
me._mod = None
def __getattr__ (me, attr) :
'import module on first attribute access'
if m._mod is None :
me._mod = importlib.import_module (me._modname)
return getattr (me._mod, attr)
Usage
import sys
math = LazyLoader ('math') # equivalent to : import math
math.ceil (1.7) # module loaded here on first access
math
is now a symbol with same scope as import statement. You can define it at the top of your module along with other imports. Or you can define it at local scope in a function or class. Decorators in other solutions only work at function scope.
If you need to set values in the module directly, such as logging.VERBOSE = 15
, then you can define __setattr__
similarly. Same with __dir__
if you want to do dir(module)
. Usually these aren't needed.
Improvement
Now a slight improvement that avoids the is none
check on every access:
class LazyLoader () :
'thin shell class to wrap modules. load real module on first access and pass thru'
def __init__ (me, modname) :
me._modname = modname
me._mod = None
def __getattr__ (me, attr) :
'import module on first attribute access'
try :
return getattr (me._mod, attr)
except Exception as e :
if me._mod is None :
# module is unset, load it
me._mod = importlib.import_module (me._modname)
else :
# module is set, got different exception from getattr (). reraise it
raise e
# retry getattr if module was just loaded for first time
# call this outside exception handler in case it raises new exception
return getattr (me._mod, attr)
# end class
Usage is the same.
Performance
# Regular top-level import
> python3 -m timeit -s 'import math' -c 'math.floor'
10000000 loops, best of 5: 32.4 nsec per loop
# Local import (same with or without -s)
> python3 -m timeit -s 'import math' -c 'import math ; math.floor'
2000000 loops, best of 5: 126 nsec per loop
# LazyLoader with exceptions
> python3 -m timeit -s 'import LazyLoader ; math = LazyLoader ("math")' -c 'math.floor'
500000 loops, best of 5: 453 nsec per loop
# LazyLoader with "if mod is none"
> python3 -m timeit -s 'import LazyLoader ; math = LazyLoader ("math")' -c 'math.floor'
500000 loops, best of 5: 540 nsec per loop
# path splitting as time comparison :
> python3 -m timeit -s 'import os; path = "/foo/bar"' -c 'os.path.split (path)'
500000 loops, best of 5: 879 nsec per loop
A 400 ns penalty is quite good. 1000 calls adds 400 microsec. A million calls adds 400 millisec. Unless your called function is very fast and you're making an ungodly number of calls, you shouldn't notice any difference.