diff --git a/tensorrt_llm/__init__.py b/tensorrt_llm/__init__.py index e7c6b2b626d2..d783bb8ad856 100644 --- a/tensorrt_llm/__init__.py +++ b/tensorrt_llm/__init__.py @@ -110,7 +110,17 @@ def __getattr__(name): entry = _LAZY_ATTRS.get(name) if entry is not None: module_name, attr = entry - module = importlib.import_module(module_name) + try: + module = importlib.import_module(module_name) + except AttributeError as e: + # An AttributeError escaping the module is a bug inside it, not a + # missing name here. Raised as is, hasattr() and getattr() with a + # default would hide it, and ``from ... import`` would run the + # module twice (losing the cause unless the name is a submodule). + raise ImportError( + f"cannot import {name!r} from {__name__!r}: importing " + f"{module_name} raised {e!r}", + name=__name__) from e value = module if attr is None else getattr(module, attr) globals()[name] = value # cache: subsequent access skips __getattr__ return value @@ -126,6 +136,12 @@ def __getattr__(name): raise raise AttributeError( f"module {__name__!r} has no attribute {name!r}") from None + except AttributeError as e: + # Same as above: a bug inside the submodule, not a missing name. + raise ImportError( + f"cannot import {name!r} from {__name__!r}: importing " + f"{__name__}.{name} raised {e!r}", + name=__name__) from e def __dir__(): diff --git a/tensorrt_llm/_torch/attention/__init__.py b/tensorrt_llm/_torch/attention/__init__.py index 20226c35c596..a90996fc4daa 100644 --- a/tensorrt_llm/_torch/attention/__init__.py +++ b/tensorrt_llm/_torch/attention/__init__.py @@ -81,9 +81,9 @@ def __getattr__(name: str): module = importlib.import_module(f".{module_name}", __name__) except AttributeError as e: # An AttributeError escaping the submodule is a bug inside it, not a - # missing name here. Raised as is, hasattr() and ``from ... import`` - # would swallow it, and from-import would run the submodule again, - # hiding the cause. + # missing name here. Raised as is, hasattr() and getattr() with a + # default would hide it, and ``from ... import`` would run the + # submodule twice (losing the cause unless the name is a submodule). raise ImportError( f"cannot import {name!r} from {__name__!r}: importing " f"{__name__}.{module_name} raised {e!r}", diff --git a/tensorrt_llm/_torch/models/__init__.py b/tensorrt_llm/_torch/models/__init__.py index 30b30c8d2f68..a9976df81177 100644 --- a/tensorrt_llm/_torch/models/__init__.py +++ b/tensorrt_llm/_torch/models/__init__.py @@ -105,24 +105,40 @@ def __getattr__(name: str): module_name = MODEL_CLASS_TO_MODULE.get(name) - if module_name is None: - # Also resolve bare submodule access (models.modeling_llama, - # models.checkpoints, models.hf_parameter_utils, ...) so callers that - # relied on the previously-eager submodule attributes keep working. + if module_name is not None: try: - return importlib.import_module(f".{name}", __name__) - except ModuleNotFoundError as e: - # Only translate "no such submodule" into AttributeError; a - # ModuleNotFoundError raised *inside* an existing submodule - # (missing dependency) must propagate unchanged. - if e.name != f"{__name__}.{name}": - raise - raise AttributeError( - f"module {__name__!r} has no attribute {name!r}") from None - module = importlib.import_module(f".{module_name}", __name__) - attr = getattr(module, name) - globals()[name] = attr # cache: subsequent access skips __getattr__ - return attr + module = importlib.import_module(f".{module_name}", __name__) + except AttributeError as e: + # An AttributeError escaping the module is a bug inside it, not a + # missing name here. Raised as is, hasattr() and getattr() with a + # default would hide it, and ``from ... import`` would run the + # module twice (losing the cause unless the name is a submodule). + raise ImportError( + f"cannot import {name!r} from {__name__!r}: importing " + f"{__name__}.{module_name} raised {e!r}", + name=__name__) from e + attr = getattr(module, name) + globals()[name] = attr # cache: subsequent access skips __getattr__ + return attr + # Also resolve bare submodule access (models.modeling_llama, + # models.checkpoints, models.hf_parameter_utils, ...) so callers that + # relied on the previously-eager submodule attributes keep working. + try: + return importlib.import_module(f".{name}", __name__) + except ModuleNotFoundError as e: + # Only translate "no such submodule" into AttributeError; a + # ModuleNotFoundError raised *inside* an existing submodule + # (missing dependency) must propagate unchanged. + if e.name != f"{__name__}.{name}": + raise + raise AttributeError( + f"module {__name__!r} has no attribute {name!r}") from None + except AttributeError as e: + # Same as above: a bug inside the submodule, not a missing name. + raise ImportError( + f"cannot import {name!r} from {__name__!r}: importing " + f"{__name__}.{name} raised {e!r}", + name=__name__) from e def __dir__(): diff --git a/tensorrt_llm/_torch/moe/fused_moe/deep_ep_utils.py b/tensorrt_llm/_torch/moe/fused_moe/deep_ep_utils.py index 702b1c3de7a9..63779c610ee9 100644 --- a/tensorrt_llm/_torch/moe/fused_moe/deep_ep_utils.py +++ b/tensorrt_llm/_torch/moe/fused_moe/deep_ep_utils.py @@ -15,7 +15,12 @@ from tensorrt_llm import deep_ep from tensorrt_llm.deep_ep import Buffer deep_ep_installed = True -except ImportError: +except ImportError as e: + # An ImportError chained to an AttributeError (from + # tensorrt_llm.__getattr__) means deep_ep raised while importing; surface + # it instead of silently disabling DeepEP. + if isinstance(e.__cause__, AttributeError): + raise deep_ep_installed = False diff --git a/tensorrt_llm/_torch/moe/fused_moe/quantization.py b/tensorrt_llm/_torch/moe/fused_moe/quantization.py index 021abd760120..9efe3a72ad1b 100644 --- a/tensorrt_llm/_torch/moe/fused_moe/quantization.py +++ b/tensorrt_llm/_torch/moe/fused_moe/quantization.py @@ -7248,6 +7248,11 @@ def _import_deep_gemm(): try: from tensorrt_llm import deep_gemm as _dg except ImportError as e: + # An ImportError chained to an AttributeError (from + # tensorrt_llm.__getattr__) means deep_gemm raised while importing; + # surface it instead of turning it into _MegaMoEUnavailable. + if isinstance(e.__cause__, AttributeError): + raise raise _MegaMoEUnavailable( f"tensorrt_llm.deep_gemm not importable: {e}") from e diff --git a/tensorrt_llm/_torch/pyexecutor/sampler/__init__.py b/tensorrt_llm/_torch/pyexecutor/sampler/__init__.py index 32779aae154a..fb7ce239eed3 100644 --- a/tensorrt_llm/_torch/pyexecutor/sampler/__init__.py +++ b/tensorrt_llm/_torch/pyexecutor/sampler/__init__.py @@ -68,11 +68,26 @@ _SUBMODULES = frozenset({"sampler", "sampler_common", "sampling_resolution", "ops"}) +def _import_submodule(name: str, submodule: str): + try: + return importlib.import_module(f"{__name__}.{submodule}") + except AttributeError as e: + # An AttributeError escaping the submodule is a bug inside it, not a + # missing name here. Raised as is, hasattr() and getattr() with a + # default would hide it, and ``from ... import`` would run the + # submodule twice (losing the cause unless the name is a submodule). + raise ImportError( + f"cannot import {name!r} from {__name__!r}: importing " + f"{__name__}.{submodule} raised {e!r}", + name=__name__, + ) from e + + def __getattr__(name: str): if name in _SUBMODULES: - return importlib.import_module(f"{__name__}.{name}") + return _import_submodule(name, name) # Lazily forward everything else to the sampler orchestration module. - _sampler = importlib.import_module(f"{__name__}.sampler") + _sampler = _import_submodule(name, "sampler") try: return getattr(_sampler, name) except AttributeError: diff --git a/tensorrt_llm/_torch/visual_gen/modules/__init__.py b/tensorrt_llm/_torch/visual_gen/modules/__init__.py index a3a58f9ad2ab..e65edbe989d4 100644 --- a/tensorrt_llm/_torch/visual_gen/modules/__init__.py +++ b/tensorrt_llm/_torch/visual_gen/modules/__init__.py @@ -34,7 +34,18 @@ def __getattr__(name): if name in __all__: - from ..attention import attention + try: + from ..attention import attention + except AttributeError as e: + # An AttributeError escaping the module is a bug inside it, not a + # missing name here. Raised as is, hasattr() and getattr() with a + # default would hide it, and ``from ... import`` would run the + # module twice (losing the cause unless the name is a submodule). + raise ImportError( + f"cannot import {name!r} from {__name__!r}: importing " + f"tensorrt_llm._torch.visual_gen.attention.attention raised {e!r}", + name=__name__, + ) from e return getattr(attention, name) raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/tensorrt_llm/visual_gen/__init__.py b/tensorrt_llm/visual_gen/__init__.py index 3b1ef26b8d98..a6181fee6cb4 100644 --- a/tensorrt_llm/visual_gen/__init__.py +++ b/tensorrt_llm/visual_gen/__init__.py @@ -94,7 +94,18 @@ def __getattr__(name): module_name = _LAZY_ATTRS.get(name) if module_name is not None: - value = getattr(importlib.import_module(module_name), name) + try: + module = importlib.import_module(module_name) + except AttributeError as e: + # An AttributeError escaping the module is a bug inside it, not a + # missing name here. Raised as is, hasattr() and getattr() with a + # default would hide it, and ``from ... import`` would run the + # module twice (losing the cause unless the name is a submodule). + raise ImportError( + f"cannot import {name!r} from {__name__!r}: importing {module_name} raised {e!r}", + name=__name__, + ) from e + value = getattr(module, name) globals()[name] = value # cache: subsequent access skips __getattr__ return value # Fall back to plain submodules (tensorrt_llm.visual_gen.sparse_attention, @@ -108,6 +119,12 @@ def __getattr__(name): if e.name != f"{__name__}.{name}": raise raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from None + except AttributeError as e: + # Same as above: a bug inside the submodule, not a missing name. + raise ImportError( + f"cannot import {name!r} from {__name__!r}: importing {__name__}.{name} raised {e!r}", + name=__name__, + ) from e def __dir__(): diff --git a/tests/unittest/_torch/attention/sparse/dsa/test_dsa_fp4_indexer.py b/tests/unittest/_torch/attention/sparse/dsa/test_dsa_fp4_indexer.py index 31fc078f3f80..be5157a1608a 100644 --- a/tests/unittest/_torch/attention/sparse/dsa/test_dsa_fp4_indexer.py +++ b/tests/unittest/_torch/attention/sparse/dsa/test_dsa_fp4_indexer.py @@ -28,9 +28,12 @@ try: from tensorrt_llm import deep_gemm -except ImportError: - # Only skip on actual module-missing failures — any other error should - # surface rather than silently turn into a test-wide skip. +except ImportError as e: + # An ImportError chained to an AttributeError (from + # tensorrt_llm.__getattr__) means deep_gemm raised while importing; surface + # it instead of skipping the tests gated on HAS_DEEP_GEMM. + if isinstance(e.__cause__, AttributeError): + raise HAS_DEEP_GEMM = False else: HAS_DEEP_GEMM = hasattr(deep_gemm, "fp8_fp4_mqa_logits") diff --git a/tests/unittest/others/test_lazy_getattr_errors.py b/tests/unittest/others/test_lazy_getattr_errors.py new file mode 100644 index 000000000000..0dc72447bba7 --- /dev/null +++ b/tests/unittest/others/test_lazy_getattr_errors.py @@ -0,0 +1,134 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Guards how the PEP 562 ``__getattr__`` of the lazily loaded packages reports a broken submodule. + +An ``AttributeError`` raised while a submodule is being imported is a bug inside that submodule. +``hasattr()``, ``getattr(..., default)`` and ``from package import name`` all treat an +``AttributeError`` from ``__getattr__`` as "no such name", so it must leave ``__getattr__`` as an +``ImportError`` chained to the original error. Otherwise ``hasattr`` returns False, +``getattr(..., default)`` returns the default, and ``from ... import`` imports the submodule a +second time and, for a name that is not itself a submodule, reports ``cannot import name`` +without the cause. + +The fault is injected with a ``sys.meta_path`` finder that raises when the target submodule is +imported, so it covers both ``importlib.import_module`` and ``from . import`` inside +``__getattr__``. +""" + +import importlib +import importlib.abc +import sys + +import pytest + +pytestmark = pytest.mark.cpu_only + +_MARKER = "injected-by-test_lazy_getattr_errors" + +# (package, name accessed on it, submodule that the access imports) +CASES = [ + ("tensorrt_llm", "AutoConfig", "tensorrt_llm.models.automodel"), + ("tensorrt_llm", "profiler", "tensorrt_llm.profiler"), + ( + "tensorrt_llm._torch.models", + "Starcoder2ForCausalLM", + "tensorrt_llm._torch.models.modeling_starcoder2", + ), + ( + "tensorrt_llm._torch.models", + "modeling_starcoder2", + "tensorrt_llm._torch.models.modeling_starcoder2", + ), + ("tensorrt_llm.visual_gen", "VisualGenParams", "tensorrt_llm.visual_gen.params"), + ("tensorrt_llm.visual_gen", "sparse_attention", "tensorrt_llm.visual_gen.sparse_attention"), + ( + "tensorrt_llm._torch.pyexecutor.sampler", + "TorchSampler", + "tensorrt_llm._torch.pyexecutor.sampler.sampler", + ), + ( + "tensorrt_llm._torch.pyexecutor.sampler", + "sampler_common", + "tensorrt_llm._torch.pyexecutor.sampler.sampler_common", + ), + ( + "tensorrt_llm._torch.visual_gen.modules", + "Attention", + "tensorrt_llm._torch.visual_gen.attention.attention", + ), +] +_IDS = [f"{pkg}.{name}" for pkg, name, _ in CASES] + + +class _RaiseOnImport(importlib.abc.MetaPathFinder): + def __init__(self, fullname: str): + self.fullname = fullname + self.attempts = 0 + + def find_spec(self, name, path=None, target=None): + if name != self.fullname: + return None + self.attempts += 1 + raise AttributeError(f"{_MARKER}: importing {name}") + + +@pytest.fixture +def broken_submodule(monkeypatch): + """Make importing ``target`` raise AttributeError until the test ends; return (package, finder).""" + + def install(pkg_name: str, name: str, target: str): + pkg = importlib.import_module(pkg_name) + parent_name, _, leaf = target.rpartition(".") + parent = importlib.import_module(parent_name) + finder = _RaiseOnImport(target) + monkeypatch.setattr(sys, "meta_path", [finder, *sys.meta_path]) + # Edit the namespaces directly: hasattr() (used by monkeypatch.delattr) would call the + # package's __getattr__ and re-import the target before the test starts. + monkeypatch.delitem(sys.modules, target, raising=False) + monkeypatch.delitem(vars(parent), leaf, raising=False) + monkeypatch.delitem(vars(pkg), name, raising=False) + assert target not in sys.modules and leaf not in vars(parent) and name not in vars(pkg) + assert finder.attempts == 0 + return pkg, finder + + return install + + +def _assert_chained(excinfo, finder): + assert isinstance(excinfo.value.__cause__, AttributeError), repr(excinfo.value) + assert _MARKER in str(excinfo.value.__cause__) + assert finder.attempts == 1 + + +@pytest.mark.parametrize("pkg_name,name,target", CASES, ids=_IDS) +def test_from_import_reports_cause_once(broken_submodule, pkg_name, name, target): + _, finder = broken_submodule(pkg_name, name, target) + with pytest.raises(ImportError) as excinfo: + exec(f"from {pkg_name} import {name}", {}) + _assert_chained(excinfo, finder) + + +@pytest.mark.parametrize("pkg_name,name,target", CASES, ids=_IDS) +@pytest.mark.parametrize("probe", ["attr", "hasattr", "getattr_default"]) +def test_probe_does_not_hide_broken_submodule(broken_submodule, probe, pkg_name, name, target): + pkg, finder = broken_submodule(pkg_name, name, target) + with pytest.raises(ImportError) as excinfo: + if probe == "attr": + getattr(pkg, name) + elif probe == "hasattr": + hasattr(pkg, name) + else: + getattr(pkg, name, None) + _assert_chained(excinfo, finder)