Skip to content

Commit 89847fb

Browse files
authored
Add a lazy_import utility function (part one of our lazy import strategy) (#330)
1 parent e40703a commit 89847fb

File tree

2 files changed

+253
-1
lines changed

2 files changed

+253
-1
lines changed

redisvl/utils/utils.py

+125-1
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,23 @@
11
import asyncio
2+
import importlib
23
import inspect
34
import json
45
import logging
6+
import sys
57
import warnings
68
from contextlib import contextmanager
79
from enum import Enum
810
from functools import wraps
911
from time import time
10-
from typing import Any, Callable, Coroutine, Dict, Optional, Sequence
12+
from typing import Any, Callable, Coroutine, Dict, Optional, Sequence, TypeVar, cast
1113
from warnings import warn
1214

1315
from pydantic import BaseModel
1416
from redis import Redis
1517
from ulid import ULID
1618

19+
T = TypeVar("T")
20+
1721

1822
def create_ulid() -> str:
1923
"""Generate a unique identifier to group related Redis documents."""
@@ -233,3 +237,123 @@ def scan_by_pattern(
233237
from redisvl.redis.utils import convert_bytes
234238

235239
return convert_bytes(list(redis_client.scan_iter(match=pattern)))
240+
241+
242+
def lazy_import(module_path: str) -> Any:
243+
"""
244+
Lazily import a module or object from a module only when it's actually used.
245+
246+
This function helps reduce startup time and avoid unnecessary dependencies
247+
by only importing modules when they are actually needed.
248+
249+
Args:
250+
module_path (str): The import path, e.g., "numpy" or "numpy.array"
251+
252+
Returns:
253+
Any: The imported module or object, or a proxy that will import it when used
254+
255+
Examples:
256+
>>> np = lazy_import("numpy")
257+
>>> # numpy is not imported yet
258+
>>> array = np.array([1, 2, 3]) # numpy is imported here
259+
260+
>>> array_func = lazy_import("numpy.array")
261+
>>> # numpy is not imported yet
262+
>>> arr = array_func([1, 2, 3]) # numpy is imported here
263+
"""
264+
parts = module_path.split(".")
265+
top_module_name = parts[0]
266+
267+
# Check if the module is already imported and we're not trying to access a specific attribute
268+
if top_module_name in sys.modules and len(parts) == 1:
269+
return sys.modules[top_module_name]
270+
271+
# Create a proxy class that will import the module when any attribute is accessed
272+
class LazyModule:
273+
def __init__(self, module_path: str):
274+
self._module_path = module_path
275+
self._module = None
276+
self._parts = module_path.split(".")
277+
278+
def _import_module(self):
279+
"""Import the module or attribute on first use"""
280+
if self._module is not None:
281+
return self._module
282+
283+
try:
284+
# Import the base module
285+
base_module_name = self._parts[0]
286+
module = importlib.import_module(base_module_name)
287+
288+
# If we're importing just the module, return it
289+
if len(self._parts) == 1:
290+
self._module = module
291+
return module
292+
293+
# Otherwise, try to get the specified attribute or submodule
294+
obj = module
295+
for part in self._parts[1:]:
296+
try:
297+
obj = getattr(obj, part)
298+
except AttributeError:
299+
# Attribute doesn't exist - we'll raise this error when the attribute is accessed
300+
return None
301+
302+
self._module = obj
303+
return obj
304+
except ImportError as e:
305+
# Store the error to raise it when the module is accessed
306+
self._import_error = e
307+
return None
308+
309+
def __getattr__(self, name: str) -> Any:
310+
# Import the module if it hasn't been imported yet
311+
if self._module is None:
312+
module = self._import_module()
313+
314+
# If import failed, raise the appropriate error
315+
if module is None:
316+
# Use direct dictionary access to avoid recursion
317+
if "_import_error" in self.__dict__:
318+
raise ImportError(
319+
f"Failed to lazily import {self._module_path}: {self._import_error}"
320+
)
321+
else:
322+
# This means we couldn't find the attribute in the module path
323+
raise AttributeError(
324+
f"{self._parts[0]} has no attribute '{self._parts[1]}'"
325+
)
326+
327+
# If we have a module, get the requested attribute
328+
if hasattr(self._module, name):
329+
return getattr(self._module, name)
330+
331+
# If the attribute doesn't exist, raise AttributeError
332+
raise AttributeError(f"{self._module_path} has no attribute '{name}'")
333+
334+
def __call__(self, *args: Any, **kwargs: Any) -> Any:
335+
# Import the module if it hasn't been imported yet
336+
if self._module is None:
337+
module = self._import_module()
338+
339+
# If import failed, raise the appropriate error
340+
if module is None:
341+
# Use direct dictionary access to avoid recursion
342+
if "_import_error" in self.__dict__:
343+
raise ImportError(
344+
f"Failed to lazily import {self._module_path}: {self._import_error}"
345+
)
346+
else:
347+
# This means we couldn't find the attribute in the module path
348+
raise ImportError(
349+
f"Failed to find {self._module_path}: module '{self._parts[0]}' has no attribute '{self._parts[1]}'"
350+
)
351+
352+
# If the imported object is callable, call it
353+
if callable(self._module):
354+
return self._module(*args, **kwargs)
355+
356+
# If it's not callable, this is an error
357+
raise TypeError(f"{self._module_path} is not callable")
358+
359+
return LazyModule(module_path)

tests/unit/test_utils.py

+128
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
denorm_cosine_distance,
1717
deprecated_argument,
1818
deprecated_function,
19+
lazy_import,
1920
norm_cosine_distance,
2021
)
2122

@@ -518,3 +519,130 @@ def test_logging_configuration_not_overridden(self):
518519
assert (
519520
has_date_pre == has_date_post
520521
), f"Date format changed: was present before: {has_date_pre}, present after: {has_date_post}"
522+
523+
524+
class TestLazyImport:
525+
def test_import_standard_library(self):
526+
"""Test lazy importing of a standard library module"""
527+
# Remove the module from sys.modules if it's already imported
528+
if "json" in sys.modules:
529+
del sys.modules["json"]
530+
531+
# Lazy import the module
532+
json = lazy_import("json")
533+
534+
# Verify the module is not imported yet
535+
assert "json" not in sys.modules
536+
537+
# Use the module, which should trigger the import
538+
result = json.dumps({"key": "value"})
539+
540+
# Verify the module is now imported
541+
assert "json" in sys.modules
542+
assert result == '{"key": "value"}'
543+
544+
def test_import_already_imported_module(self):
545+
"""Test lazy importing of an already imported module"""
546+
# Make sure the module is imported
547+
import math
548+
549+
assert "math" in sys.modules
550+
551+
# Lazy import the module
552+
math_lazy = lazy_import("math")
553+
554+
# Since the module is already imported, it should be returned directly
555+
assert math_lazy is sys.modules["math"]
556+
557+
# Use the module
558+
assert math_lazy.sqrt(4) == 2.0
559+
560+
def test_import_submodule(self):
561+
"""Test lazy importing of a submodule"""
562+
# Remove the module from sys.modules if it's already imported
563+
if "os.path" in sys.modules:
564+
del sys.modules["os.path"]
565+
if "os" in sys.modules:
566+
del sys.modules["os"]
567+
568+
# Lazy import the submodule
569+
path = lazy_import("os.path")
570+
571+
# Verify the module is not imported yet
572+
assert "os" not in sys.modules
573+
574+
# Use the submodule, which should trigger the import
575+
result = path.join("dir", "file.txt")
576+
577+
# Verify the module is now imported
578+
assert "os" in sys.modules
579+
assert (
580+
result == "dir/file.txt" or result == "dir\\file.txt"
581+
) # Handle Windows paths
582+
583+
def test_import_function(self):
584+
"""Test lazy importing of a function"""
585+
# Remove the module from sys.modules if it's already imported
586+
if "math" in sys.modules:
587+
del sys.modules["math"]
588+
589+
# Lazy import the function
590+
sqrt = lazy_import("math.sqrt")
591+
592+
# Verify the module is not imported yet
593+
assert "math" not in sys.modules
594+
595+
# Use the function, which should trigger the import
596+
result = sqrt(4)
597+
598+
# Verify the module is now imported
599+
assert "math" in sys.modules
600+
assert result == 2.0
601+
602+
def test_import_nonexistent_module(self):
603+
"""Test lazy importing of a nonexistent module"""
604+
# Lazy import a nonexistent module
605+
nonexistent = lazy_import("nonexistent_module_xyz")
606+
607+
# Accessing an attribute should raise ImportError
608+
with pytest.raises(ImportError) as excinfo:
609+
nonexistent.some_attribute
610+
611+
assert "Failed to lazily import nonexistent_module_xyz" in str(excinfo.value)
612+
613+
def test_import_nonexistent_attribute(self):
614+
"""Test lazy importing of a nonexistent attribute"""
615+
# Lazy import a nonexistent attribute
616+
nonexistent_attr = lazy_import("math.nonexistent_attribute")
617+
618+
# Accessing the attribute should raise ImportError
619+
with pytest.raises(ImportError) as excinfo:
620+
nonexistent_attr()
621+
622+
assert "module 'math' has no attribute 'nonexistent_attribute'" in str(
623+
excinfo.value
624+
)
625+
626+
def test_import_noncallable(self):
627+
"""Test calling a non-callable lazy imported object"""
628+
# Lazy import a non-callable attribute
629+
pi = lazy_import("math.pi")
630+
631+
# Calling it should raise TypeError
632+
with pytest.raises(TypeError) as excinfo:
633+
pi()
634+
635+
assert "math.pi is not callable" in str(excinfo.value)
636+
637+
def test_attribute_error(self):
638+
"""Test accessing a nonexistent attribute on a lazy imported module"""
639+
# Lazy import a module
640+
math = lazy_import("math")
641+
642+
# Accessing a nonexistent attribute should raise AttributeError
643+
with pytest.raises(AttributeError) as excinfo:
644+
math.nonexistent_attribute
645+
646+
assert "module 'math' has no attribute 'nonexistent_attribute'" in str(
647+
excinfo.value
648+
)

0 commit comments

Comments
 (0)