diff --git a/_python_utils_tests/test_import.py b/_python_utils_tests/test_import.py index bcdf773..132a355 100644 --- a/_python_utils_tests/test_import.py +++ b/_python_utils_tests/test_import.py @@ -3,6 +3,7 @@ import collections.abc import pathlib import sys +from types import ModuleType import pytest @@ -203,3 +204,32 @@ def test_import_global_missing_below_detached_module( assert type(error) is ImportError assert str(error) == f'No module named {name}' + + +@pytest.mark.parametrize( + 'names', [None, ['virtual', 'none', '_private', 'missing']] +) +def test_import_global_lazy_module_exports( + monkeypatch: pytest.MonkeyPatch, names: list[str] | None +) -> None: + """Read declared and explicit exports through module __getattr__.""" + module_name = 'python_utils_lazy_export_fixture' + module = ModuleType(module_name) + calls: dict[str, int] = {} + + def lookup(name: str) -> types.Any: + """Provide public names that are deliberately absent from dir().""" + calls[name] = calls.get(name, 0) + 1 + if name == 'virtual': + return 42 + if name == 'none': + return None + raise AttributeError(name) + + module.__dict__['__getattr__'] = lookup + module.__dict__['__all__'] = ['virtual', 'none', '_private', 'missing'] + monkeypatch.setitem(sys.modules, module_name, module) + scope: dict[str, types.Any] = {} + import_.import_global(module_name, names, locals_={}, globals_=scope) + assert scope == {'virtual': 42, 'none': None} + assert calls['virtual'] == calls['none'] == 1 diff --git a/python_utils/import_.py b/python_utils/import_.py index 1b070ba..ef45c17 100644 --- a/python_utils/import_.py +++ b/python_utils/import_.py @@ -87,7 +87,8 @@ def import_global( # noqa: C901 for everything. An empty list also imports everything. A single name needs a list as well, a bare string is read as a collection of one-character names. Names that start with an underscore are - never imported. + never imported. Declared or explicitly requested names may be + provided lazily by the module, even if absent from ``dir()``. exceptions (Exception): the exception to catch, e.g. ImportError locals_: the `locals()` method (in case you need a different scope) globals_: the `globals()` method (in case you need a different scope) @@ -136,12 +137,15 @@ def import_global( # noqa: C901 if not modules: modules_set = set(getattr(module, '__all__', dir(module))) else: - modules_set = set(modules).intersection(dir(module)) + modules_set = set(modules) # Add all items in modules to the global scope - for k in set(dir(module)).intersection(modules_set): + missing = object() + for k in modules_set: if k and k[0] != '_': - globals_[k] = getattr(module, k) + value = getattr(module, k, missing) + if value is not missing: + globals_[k] = value except exceptions as e: return e finally: