Skip to content

Commit 676d9c8

Browse files
committed
fix: lazy import utilities
1 parent 8407979 commit 676d9c8

3 files changed

Lines changed: 135 additions & 19 deletions

File tree

bec_lib/bec_lib/utils/__init__.py

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,25 @@
1-
from bec_lib.utils.rpc_utils import user_access
2-
from bec_lib.utils.scan_utils import scan_to_csv, scan_to_dict
3-
from bec_lib.utils.threading_utils import threadlocked
1+
from importlib import import_module
2+
3+
_LAZY_EXPORTS = {
4+
"lazy_import": ("bec_lib.utils.import_utils", "lazy_import"),
5+
"lazy_import_from": ("bec_lib.utils.import_utils", "lazy_import_from"),
6+
"scan_to_csv": ("bec_lib.utils.scan_utils", "scan_to_csv"),
7+
"scan_to_dict": ("bec_lib.utils.scan_utils", "scan_to_dict"),
8+
"threadlocked": ("bec_lib.utils.threading_utils", "threadlocked"),
9+
"user_access": ("bec_lib.utils.rpc_utils", "user_access"),
10+
}
11+
12+
__all__ = sorted(_LAZY_EXPORTS)
13+
14+
15+
def __getattr__(name: str):
16+
if name not in _LAZY_EXPORTS:
17+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
18+
module_name, attr_name = _LAZY_EXPORTS[name]
19+
value = getattr(import_module(module_name), attr_name)
20+
globals()[name] = value
21+
return value
22+
23+
24+
def __dir__() -> list[str]:
25+
return sorted(set(globals()) | set(__all__))
Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,40 @@
1-
import inspect
2-
import sys
1+
from collections.abc import Sequence
32
from importlib import import_module
4-
from typing import Any
3+
from typing import Any, overload
54

65
from bec_lib.utils.proxy import Proxy
76

87

9-
def lazy_import(module_name):
8+
def lazy_import(module_name: str) -> Proxy:
109
return Proxy(lambda: import_module(module_name), init_once=True)
1110

1211

13-
def lazy_import_from(module_name, from_list):
14-
ret = (Proxy(lambda name=name: getattr(import_module(module_name), name)) for name in from_list)
15-
if len(from_list) == 1:
16-
return next(ret)
17-
else:
18-
return ret
12+
@overload
13+
def lazy_import_from(module_name: str, from_list: str) -> Proxy: ...
14+
15+
16+
@overload
17+
def lazy_import_from(module_name: str, from_list: Sequence[str]) -> tuple[Proxy, ...] | Proxy: ...
18+
19+
20+
def lazy_import_from(module_name: str, from_list: str | Sequence[str]) -> tuple[Proxy, ...] | Proxy:
21+
names = (from_list,) if isinstance(from_list, str) else tuple(from_list)
22+
proxies = tuple(
23+
Proxy(lambda name=name: getattr(import_module(module_name), name), init_once=True)
24+
for name in names
25+
)
26+
if len(proxies) == 1:
27+
return proxies[0]
28+
return proxies
1929

2030

2131
def isinstance_based_on_class_name(obj: Any, full_class_name: str):
2232
"""Return if object 'obj' is an instance of class named 'full_class_name'
2333
2434
'full_class_name' must be a string like 'class_module.class_name', the corresponding class does not need to be imported at the caller module level
2535
"""
36+
import inspect
37+
2638
return full_class_name in [
2739
f"{klass.__module__}.{klass.__name__}" for klass in inspect.getmro(type(obj))
2840
]

bec_lib/tests/test_import_utils.py

Lines changed: 88 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,91 @@
1-
from bec_lib.device import DeviceBase
2-
from bec_lib.utils.import_utils import isinstance_based_on_class_name
1+
import os
2+
import subprocess
3+
import sys
4+
from types import SimpleNamespace
35

6+
from bec_lib.utils import import_utils
47

5-
def test_isinstance_based_on_class_name():
6-
obj = DeviceBase(name="test_obj")
78

8-
assert isinstance_based_on_class_name(obj, "bec_lib.device.DeviceBase")
9-
assert not isinstance_based_on_class_name(obj, "bec_lib.device.Status")
9+
def _clean_pythonpath() -> str:
10+
return os.pathsep.join(str(path) for path in sys.path if path)
11+
12+
13+
def test_lazy_import_from_accepts_string_input():
14+
json_decoder = import_utils.lazy_import_from("json", "JSONDecoder")
15+
assert json_decoder.__name__ == "JSONDecoder"
16+
17+
18+
def test_lazy_import_from_single_tuple_returns_single_proxy():
19+
json_decoder = import_utils.lazy_import_from("json", ("JSONDecoder",))
20+
assert json_decoder.__name__ == "JSONDecoder"
21+
22+
23+
def test_lazy_import_from_multiple_names_returns_tuple():
24+
proxies = import_utils.lazy_import_from("json", ("JSONDecoder", "JSONEncoder"))
25+
assert isinstance(proxies, tuple)
26+
assert [proxy.__name__ for proxy in proxies] == ["JSONDecoder", "JSONEncoder"]
27+
28+
29+
def test_lazy_import_from_materializes_once(monkeypatch):
30+
calls = []
31+
32+
def fake_import(module_name):
33+
calls.append(module_name)
34+
return SimpleNamespace(DemoClass=type("DemoClass", (), {}))
35+
36+
monkeypatch.setattr(import_utils, "import_module", fake_import)
37+
38+
demo_class = import_utils.lazy_import_from("demo.module", "DemoClass")
39+
assert demo_class.__name__ == "DemoClass"
40+
assert demo_class.__name__ == "DemoClass"
41+
assert calls == ["demo.module"]
42+
43+
44+
def test_lazy_import_does_not_import_module_until_use(tmp_path, monkeypatch):
45+
module_name = "lazy_target_module"
46+
module_path = tmp_path / "lazy_target_module.py"
47+
module_path.write_text("VALUE = 123\n", encoding="utf-8")
48+
monkeypatch.syspath_prepend(str(tmp_path))
49+
sys.modules.pop(module_name, None)
50+
51+
mod = import_utils.lazy_import(module_name)
52+
53+
assert module_name not in sys.modules
54+
assert mod.VALUE == 123
55+
assert module_name in sys.modules
56+
57+
58+
def test_lazy_import_from_does_not_import_module_until_use(tmp_path, monkeypatch):
59+
module_name = "lazy_from_target_module"
60+
module_path = tmp_path / "lazy_from_target_module.py"
61+
module_path.write_text(
62+
"class DemoClass:\n"
63+
" VALUE = 456\n",
64+
encoding="utf-8",
65+
)
66+
monkeypatch.syspath_prepend(str(tmp_path))
67+
sys.modules.pop(module_name, None)
68+
69+
demo_cls = import_utils.lazy_import_from(module_name, "DemoClass")
70+
71+
assert module_name not in sys.modules
72+
assert demo_cls.VALUE == 456
73+
assert module_name in sys.modules
74+
75+
76+
def test_importing_import_utils_does_not_import_scan_utils():
77+
# This needs a clean interpreter because sys.modules is shared by the test process.
78+
env = os.environ | {"PYTHONPATH": _clean_pythonpath()}
79+
proc = subprocess.run(
80+
[
81+
sys.executable,
82+
"-c",
83+
"from bec_lib.utils.import_utils import lazy_import_from; import sys; "
84+
"print('bec_lib.utils.scan_utils' in sys.modules)",
85+
],
86+
check=True,
87+
capture_output=True,
88+
text=True,
89+
env=env,
90+
)
91+
assert proc.stdout.strip() == "False"

0 commit comments

Comments
 (0)