Skip to content

Commit 1ec71e0

Browse files
Fix lazy import for callable attributes of a module.
1 parent 09cd9e6 commit 1ec71e0

File tree

5 files changed

+170
-11
lines changed

5 files changed

+170
-11
lines changed

docs/changes.rst

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,25 @@ Version 2.0.1
1111
accessible when using ``from wrapt import *`` and type checkers such as
1212
``mypy`` or ``pylance`` may not see it as part of the public API.
1313

14+
* When using ``wrapt.lazy_import()`` to lazily import a function of a module,
15+
the resulting proxy object wasn't marked as callable until something triggered
16+
the import of the module via the proxy. This meant a ``callable()`` check
17+
on the proxy would return ``False`` until the module was actually imported.
18+
Further, calling the proxy before the module was imported would raise
19+
``TypeError: 'LazyObjectProxy' object is not callable`` rather than
20+
importing the module and calling the function as expected. In order to
21+
address this issue, an additional keyword argument ``interface`` has been
22+
added to ``wrapt.lazy_import()`` which can be used to specify the expected
23+
interface type of the wrapped object. This will default to ``Callable``
24+
when an attribute name is supplied, and to ``ModuleType`` when no attribute
25+
name is supplied. If using ``wrapt.lazy_import()`` and supplying an
26+
``attribute`` argument, and you expect the wrapped object to be something
27+
other than a callable, you should now also supply ``interface=...`` with the
28+
appropriate type from ``collections.abc`` to ensure the proxy behaves correctly
29+
prior to the module being imported. This should only be necessary where the
30+
wrapped object has special dunder methods on its type which need to exist on
31+
the proxy prior to the module being imported.
32+
1433
Version 2.0.0
1534
-------------
1635

docs/wrappers.rst

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -667,14 +667,21 @@ and used as the wrapped object instead of the module itself.
667667

668668
::
669669

670-
def lazy_import(name, attribute=None):
670+
def lazy_import(name, attribute=None, *, interface=...):
671671
"""Lazily imports the module `name`, returning a `LazyObjectProxy` which
672672
will import the module when it is first needed. When `name is a dotted name,
673673
then the full dotted name is imported and the last module is taken as the
674674
target. If `attribute` is provided then it is used to retrieve an attribute
675675
from the module.
676676
"""
677677

678+
if attribute is not None:
679+
if interface is ...:
680+
interface = Callable
681+
else:
682+
if interface is ...:
683+
interface = ModuleType
684+
678685
def _import():
679686
module = __import__(name, fromlist=[""])
680687

@@ -683,7 +690,18 @@ and used as the wrapped object instead of the module itself.
683690

684691
return module
685692

686-
return LazyObjectProxy(_import)
693+
return LazyObjectProxy(_import, interface=interface)
694+
695+
The ``interface`` argument is a hint as to the type of object being wrapped.
696+
This is used to help ``LazyObjectProxy`` determine what special methods need to
697+
be added to the proxy in order to properly stand in for the wrapped object
698+
prior to the module being imported, at which point the required special methods
699+
can be determined from the actual wrapped object. This is important for cases
700+
such as where the wrapped object is a callable, as the proxy will need to
701+
implement ``__call__()`` so that calling it will trigger the import of the module
702+
and then call the wrapped callable. If dealing with attribute of a module which
703+
have a different interface type, such as an iterable, then the appropriate type
704+
from ``collections.abc`` should be used.
687705

688706
Since such a lazy import feature is generally useful, a convenience function
689707
``wrapt.lazy_import()`` is provided which implements the above example.

src/wrapt/__init__.pyi

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,16 @@ if sys.version_info >= (3, 10):
3737
# LazyObjectProxy
3838

3939
class LazyObjectProxy(AutoObjectProxy[T]):
40-
def __init__(self, callback: Callable[[], T] | None) -> None: ...
40+
def __init__(
41+
self, callback: Callable[[], T] | None, *, interface: Any = ...
42+
) -> None: ...
4143

4244
@overload
4345
def lazy_import(name: str) -> LazyObjectProxy[ModuleType]: ...
4446
@overload
45-
def lazy_import(name: str, attribute: str) -> LazyObjectProxy[Any]: ...
47+
def lazy_import(
48+
name: str, attribute: str, *, interface: Any = ...
49+
) -> LazyObjectProxy[Any]: ...
4650

4751
# CallableObjectProxy
4852

src/wrapt/proxies.py

Lines changed: 76 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
"""Variants of ObjectProxy for different use cases."""
22

3+
from collections.abc import Callable
4+
from types import ModuleType
5+
36
from .__wrapt__ import BaseObjectProxy
47
from .decorators import synchronized
58

@@ -30,7 +33,12 @@ def __iter__(self):
3033
# object and add special dunder methods.
3134

3235

33-
def __wrapper_call__(self, *args, **kwargs):
36+
def __wrapper_call__(*args, **kwargs):
37+
def _unpack_self(self, *args):
38+
return self, args
39+
40+
self, args = _unpack_self(*args)
41+
3442
return self.__wrapped__(*args, **kwargs)
3543

3644

@@ -136,7 +144,7 @@ def __new__(cls, wrapped):
136144
if cls is AutoObjectProxy:
137145
name = BaseObjectProxy.__name__
138146

139-
return super().__new__(type(name, (cls,), namespace))
147+
return super(AutoObjectProxy, cls).__new__(type(name, (cls,), namespace))
140148

141149
def __wrapped_setattr_fixups__(self):
142150
"""Adjusts special dunder methods on the class as needed based on the
@@ -218,10 +226,64 @@ class LazyObjectProxy(AutoObjectProxy):
218226
when it is first needed.
219227
"""
220228

221-
def __new__(cls, callback=None):
222-
return super().__new__(cls, None)
229+
def __new__(cls, callback=None, *, interface=...):
230+
"""Injects special dunder methods into a dynamically created subclass
231+
as needed based on the wrapped object.
232+
"""
233+
234+
if interface is ...:
235+
interface = type(None)
236+
237+
namespace = {}
238+
239+
interface_attrs = dir(interface)
240+
class_attrs = set(dir(cls))
241+
242+
if "__call__" in interface_attrs and "__call__" not in class_attrs:
243+
namespace["__call__"] = __wrapper_call__
244+
245+
if "__iter__" in interface_attrs and "__iter__" not in class_attrs:
246+
namespace["__iter__"] = __wrapper_iter__
247+
248+
if "__next__" in interface_attrs and "__next__" not in class_attrs:
249+
namespace["__next__"] = __wrapper_next__
250+
251+
if "__aiter__" in interface_attrs and "__aiter__" not in class_attrs:
252+
namespace["__aiter__"] = __wrapper_aiter__
253+
254+
if "__anext__" in interface_attrs and "__anext__" not in class_attrs:
255+
namespace["__anext__"] = __wrapper_anext__
223256

224-
def __init__(self, callback=None):
257+
if (
258+
"__length_hint__" in interface_attrs
259+
and "__length_hint__" not in class_attrs
260+
):
261+
namespace["__length_hint__"] = __wrapper_length_hint__
262+
263+
# Note that not providing compatibility with generator-based coroutines
264+
# (PEP 342) here as they are removed in Python 3.11+ and were deprecated
265+
# in 3.8.
266+
267+
if "__await__" in interface_attrs and "__await__" not in class_attrs:
268+
namespace["__await__"] = __wrapper_await__
269+
270+
if "__get__" in interface_attrs and "__get__" not in class_attrs:
271+
namespace["__get__"] = __wrapper_get__
272+
273+
if "__set__" in interface_attrs and "__set__" not in class_attrs:
274+
namespace["__set__"] = __wrapper_set__
275+
276+
if "__delete__" in interface_attrs and "__delete__" not in class_attrs:
277+
namespace["__delete__"] = __wrapper_delete__
278+
279+
if "__set_name__" in interface_attrs and "__set_name__" not in class_attrs:
280+
namespace["__set_name__"] = __wrapper_set_name__
281+
282+
name = cls.__name__
283+
284+
return super(AutoObjectProxy, cls).__new__(type(name, (cls,), namespace))
285+
286+
def __init__(self, callback=None, *, interface=...):
225287
"""Initialize the object proxy with wrapped object as `None` but due
226288
to presence of special `__wrapped_factory__` attribute addded first,
227289
this will actually trigger the deferred creation of the wrapped object
@@ -263,14 +325,21 @@ def __wrapped_get__(self):
263325
return self.__wrapped__
264326

265327

266-
def lazy_import(name, attribute=None):
328+
def lazy_import(name, attribute=None, *, interface=...):
267329
"""Lazily imports the module `name`, returning a `LazyObjectProxy` which
268330
will import the module when it is first needed. When `name is a dotted name,
269331
then the full dotted name is imported and the last module is taken as the
270332
target. If `attribute` is provided then it is used to retrieve an attribute
271333
from the module.
272334
"""
273335

336+
if attribute is not None:
337+
if interface is ...:
338+
interface = Callable
339+
else:
340+
if interface is ...:
341+
interface = ModuleType
342+
274343
def _import():
275344
module = __import__(name, fromlist=[""])
276345

@@ -279,4 +348,4 @@ def _import():
279348

280349
return module
281350

282-
return LazyObjectProxy(_import)
351+
return LazyObjectProxy(_import, interface=interface)

tests/core/test_lazy_object_proxy.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,3 +60,52 @@ def test_lazy_import_dotted(self):
6060
self.assertIsNotNone(client_mod.HTTPConnection)
6161

6262
self.assertTrue("http.client" in sys.modules)
63+
64+
def test_lazy_import_callable(self):
65+
dumps = wrapt.lazy_import("json", "dumps")
66+
67+
self.assertTrue(callable(dumps))
68+
69+
result = dumps({"key": "value"})
70+
71+
self.assertEqual(result, '{"key": "value"}')
72+
73+
def test_lazy_import_iterable(self):
74+
from collections.abc import Iterable
75+
76+
if "string" in sys.modules:
77+
del sys.modules["string"]
78+
79+
digits0 = wrapt.lazy_import("string", "digits")
80+
81+
self.assertFalse(hasattr(type(digits0), "__iter__"))
82+
83+
self.assertTrue(callable(digits0))
84+
85+
# XXX Iteration does not seem to test for __iter__ being a method
86+
# on the type, so this test is not sufficient to raise the TypeError.
87+
# I was at some point seeing expected failures here, but not now and
88+
# I don't know why.
89+
90+
# if "string" in sys.modules:
91+
# del sys.modules["string"]
92+
93+
# digits1 = wrapt.lazy_import("string", "digits")
94+
95+
# with self.assertRaises(TypeError):
96+
# for char in digits1:
97+
# pass
98+
99+
if "string" in sys.modules:
100+
del sys.modules["string"]
101+
102+
digits2 = wrapt.lazy_import("string", "digits", interface=Iterable)
103+
104+
self.assertTrue(hasattr(type(digits2), "__iter__"))
105+
106+
self.assertFalse(callable(digits2))
107+
108+
for char in digits2:
109+
self.assertIn(char, "0123456789")
110+
111+
self.assertEqual("".join(digits2), "0123456789")

0 commit comments

Comments
 (0)