diff --git a/src/google/adk/utils/_lazy.py b/src/google/adk/utils/_lazy.py index b5f06b14..481760b9 100644 --- a/src/google/adk/utils/_lazy.py +++ b/src/google/adk/utils/_lazy.py @@ -36,12 +36,33 @@ def accessors( package: str = module_globals['__name__'] def module_getattr(name: str) -> Any: - if name not in members: + if name in members: + module = importlib.import_module(members[name], package) + value = getattr(module, name) + module_globals[name] = value + return value + + # Protocol probes ask for dunders that a package never resolves lazily, + # and a failed import is not cached, so answering them here would repeat + # the whole finder walk on every copy, pickle or introspection call. + if name.startswith('__') and name.endswith('__'): raise AttributeError(f'module {package!r} has no attribute {name!r}') - module = importlib.import_module(members[name], package) - value = getattr(module, name) - module_globals[name] = value - return value + + # Importing a subpackage eagerly used to bind it on its parent, so + # ``package.subpackage`` resolved without importing it by name first. + # Resolve it on demand to keep that working, which the import system + # then caches by binding the submodule on this package. + submodule = f'{package}.{name}' + try: + return importlib.import_module(submodule) + except ModuleNotFoundError as error: + # Anything missing deeper than this name is a real dependency error and + # has to keep its own message rather than becoming a typo report. + if error.name != submodule: + raise + raise AttributeError( + f'module {package!r} has no attribute {name!r}' + ) from None def module_dir() -> list[str]: return sorted(set(module_globals) | set(module_globals.get('__all__', ()))) diff --git a/tests/unittests/test_import_loading.py b/tests/unittests/test_import_loading.py index 24193917..477733ee 100644 --- a/tests/unittests/test_import_loading.py +++ b/tests/unittests/test_import_loading.py @@ -136,6 +136,20 @@ for module_name in {_LAZY_PACKAGES!r}: assert result.returncode == 0, result.stderr +def test_lazy_packages_resolve_subpackages_as_attributes(): + """A subpackage stays reachable on its parent, as eager imports left it.""" + result = run_isolated(""" +import types + +import google.adk + +for name in ('agents', 'events', 'runners', 'sessions', 'tools'): + assert isinstance(getattr(google.adk, name), types.ModuleType), name +""") + + assert result.returncode == 0, result.stderr + + def test_lazy_packages_reject_unknown_attributes(): """The lazy hook raises AttributeError rather than masking typos.""" result = run_isolated(f""" diff --git a/tests/unittests/utils/test_lazy.py b/tests/unittests/utils/test_lazy.py new file mode 100644 index 00000000..b1757c29 --- /dev/null +++ b/tests/unittests/utils/test_lazy.py @@ -0,0 +1,66 @@ +# Copyright 2026 Google LLC +# +# 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. + +"""Tests for the lazy package accessors.""" + +from __future__ import annotations + +import importlib +import types + +from google.adk.utils import _lazy +import pytest + + +@pytest.fixture(name='package') +def _package(tmp_path, monkeypatch) -> types.ModuleType: + """A package with a plain submodule and one that needs an absent library.""" + root = tmp_path / 'lazy_fixture_pkg' + root.mkdir() + (root / '__init__.py').write_text('') + (root / 'plain.py').write_text('VALUE = 1\n') + (root / 'needs_absent.py').write_text('import absent_dependency\n') + (root / 'exports.py').write_text('Exported = object()\n') + monkeypatch.syspath_prepend(str(tmp_path)) + return importlib.import_module('lazy_fixture_pkg') + + +def test_declared_member_resolves_from_its_module(package): + getattr_, _ = _lazy.accessors(vars(package), {'Exported': '.exports'}) + + exports = importlib.import_module('lazy_fixture_pkg.exports') + assert getattr_('Exported') is exports.Exported + + +def test_submodule_resolves_as_an_attribute(package): + getattr_, _ = _lazy.accessors(vars(package), {}) + + assert getattr_('plain').VALUE == 1 + + +def test_unknown_name_raises_attribute_error(package): + getattr_, _ = _lazy.accessors(vars(package), {}) + + with pytest.raises(AttributeError, match='no attribute'): + getattr_('not_a_submodule') + + +def test_absent_dependency_keeps_its_own_error(package): + """A library missing inside a submodule must not read as a typo.""" + getattr_, _ = _lazy.accessors(vars(package), {}) + + with pytest.raises(ModuleNotFoundError) as error: + getattr_('needs_absent') + + assert error.value.name == 'absent_dependency'