From 0a91875211efad91829d20e3bd8a0d8dba8513d9 Mon Sep 17 00:00:00 2001 From: Lin Guo Date: Sun, 23 Aug 2026 22:03:48 -0700 Subject: [PATCH] Fix object import Previously things like `import ... as` is not supported. Change to use `importlib` to support such standard access patterns. Also unify the previous `ObjectNamespace` and `RepositoryNamespace`, into a single `RambleNamespace`. Signed-off-by: Lin Guo --- lib/ramble/ramble/repository.py | 50 +++++++++------------------- lib/ramble/ramble/test/repository.py | 32 ++++++++++++++++++ 2 files changed, 48 insertions(+), 34 deletions(-) diff --git a/lib/ramble/ramble/repository.py b/lib/ramble/ramble/repository.py index ac06e2b6b8..0496ac8824 100644 --- a/lib/ramble/ramble/repository.py +++ b/lib/ramble/ramble/repository.py @@ -11,6 +11,7 @@ import contextlib import errno import functools +import importlib import importlib.machinery import importlib.util import inspect @@ -425,7 +426,7 @@ def converter(self, spec_like, *args, **kwargs): return converter -class ObjectNamespace(types.ModuleType): +class RambleNamespace(types.ModuleType): """Allow lazy loading of modules.""" def __init__(self, namespace): @@ -433,13 +434,16 @@ def __init__(self, namespace): self.__file__ = "(ramble namespace)" self.__path__ = [] self.__name__ = namespace - self.__application__ = namespace self.__modules = {} def __getattr__(self, name): """Getattr lazily loads modules if they're not already loaded.""" - submodule = self.__application__ + "." + name - setattr(self, name, __import__(submodule)) + submodule = f"{self.__name__}.{name}" + try: + setattr(self, name, importlib.import_module(submodule)) + except ImportError: + msg = "'{0}' object has no attribute {1}" + raise AttributeError(msg.format(type(self), name)) from None return getattr(self, name) @@ -881,7 +885,7 @@ def load_module(self, fullname): if not self.by_namespace.is_prefix(fullname): raise ImportError(f"No such ramble repo: {fullname}") - module = ObjectNamespace(fullname) + module = RambleNamespace(fullname) module.__loader__ = self sys.modules[fullname] = module return module @@ -1075,7 +1079,7 @@ def _create_namespace(self): ns = ".".join(self._names[:i]) if ns not in sys.modules: - module = ObjectNamespace(ns) + module = RambleNamespace(ns) module.__loader__ = self sys.modules[ns] = module @@ -1151,7 +1155,7 @@ def load_module(self, fullname): namespace, _, module_name = fullname.rpartition(".") if self.is_prefix(fullname): - module = ObjectNamespace(fullname) + module = RambleNamespace(fullname) elif namespace == self.full_namespace: real_name = self.real_name(module_name) @@ -1498,28 +1502,6 @@ def create(configuration, object_type=default_type): return RepoPath(*repo_dirs, object_type=object_type) -class RepositoryNamespace(types.ModuleType): - """Allow lazy loading of modules.""" - - def __init__(self, namespace): - super().__init__(namespace) - self.__file__ = "(repository namespace)" - self.__path__ = [] - self.__name__ = namespace - self.__package__ = namespace - self.__modules = {} - - def __getattr__(self, name): - """Getattr lazily loads modules if they're not already loaded.""" - submodule = self.__package__ + "." + name - try: - setattr(self, name, __import__(submodule)) - except ImportError: - msg = "'{0}' object has no attribute {1}" - raise AttributeError(msg.format(type(self), name)) from None - return getattr(self, name) - - class RepoLoader(importlib.machinery.SourceFileLoader): """Loads a Python module associated with a object in specific repository""" @@ -1536,9 +1518,9 @@ def is_package(self, fullname): return True -class RepositoryNamespaceLoader: +class RambleNamespaceLoader: def create_module(self, spec): - return RepositoryNamespace(spec.name) + return RambleNamespace(spec.name) def exec_module(self, module): module.__loader__ = self @@ -1581,14 +1563,14 @@ def compute_loader(self, fullname): if object_name: return RepoLoader(fullname, repo, object_name) - # We are importing a full namespace like 'spack.pkg.builtin' + # We are importing a full namespace like 'ramble.app.builtin' if fullname == repo.full_namespace: - return RepositoryNamespaceLoader() + return RambleNamespaceLoader() # No repo provides the namespace, but it is a valid prefix of # something in the RepoPath. if paths[self.object_type].by_namespace.is_prefix(fullname): - return RepositoryNamespaceLoader() + return RambleNamespaceLoader() return None diff --git a/lib/ramble/ramble/test/repository.py b/lib/ramble/ramble/test/repository.py index 40c8a2eb27..325fd25880 100644 --- a/lib/ramble/ramble/test/repository.py +++ b/lib/ramble/ramble/test/repository.py @@ -218,3 +218,35 @@ def test_use_repositories_exception_cleanup(extra_repo): raise RuntimeError("test error inside context") assert ramble.repository.paths[ramble.repository.ObjectTypes.applications] is orig_path assert sys.meta_path == orig_meta_path + + +def test_namespace_import_and_attribute_access(mutable_mock_apps_repo): + import ramble.app.builtin.mock.basic as mock_basic + + assert hasattr(mock_basic, "Basic") + + import ramble.app.builtin.mock as mock_ns + + assert hasattr(mock_ns, "basic") + assert mock_ns.basic.Basic.name == "basic" + + import ramble.app as app_ns + + assert hasattr(app_ns.builtin.mock, "basic") + assert app_ns.builtin.mock.basic.Basic.name == "basic" + + +def test_namespace_nonexistent_attribute(mutable_mock_apps_repo): + import ramble.app.builtin.mock as mock_ns + + assert not hasattr(mock_ns, "nonexistent_object") + assert getattr(mock_ns, "nonexistent_object", "default") == "default" + with pytest.raises(AttributeError): + _ = mock_ns.nonexistent_object + + import ramble.app as app_ns + + assert not hasattr(app_ns, "nonexistent_subnamespace") + assert getattr(app_ns, "nonexistent_subnamespace", None) is None + with pytest.raises(AttributeError): + _ = app_ns.nonexistent_subnamespace