Skip to content

Commit ae5118d

Browse files
GWealecopybara-github
authored andcommitted
fix: keep subpackages reachable as attributes of a lazy package
Co-authored-by: George Weale <gweale@google.com> PiperOrigin-RevId: 964141603
1 parent 957dc2b commit ae5118d

3 files changed

Lines changed: 106 additions & 5 deletions

File tree

src/google/adk/utils/_lazy.py

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,33 @@ def accessors(
3636
package: str = module_globals['__name__']
3737

3838
def module_getattr(name: str) -> Any:
39-
if name not in members:
39+
if name in members:
40+
module = importlib.import_module(members[name], package)
41+
value = getattr(module, name)
42+
module_globals[name] = value
43+
return value
44+
45+
# Protocol probes ask for dunders that a package never resolves lazily,
46+
# and a failed import is not cached, so answering them here would repeat
47+
# the whole finder walk on every copy, pickle or introspection call.
48+
if name.startswith('__') and name.endswith('__'):
4049
raise AttributeError(f'module {package!r} has no attribute {name!r}')
41-
module = importlib.import_module(members[name], package)
42-
value = getattr(module, name)
43-
module_globals[name] = value
44-
return value
50+
51+
# Importing a subpackage eagerly used to bind it on its parent, so
52+
# ``package.subpackage`` resolved without importing it by name first.
53+
# Resolve it on demand to keep that working, which the import system
54+
# then caches by binding the submodule on this package.
55+
submodule = f'{package}.{name}'
56+
try:
57+
return importlib.import_module(submodule)
58+
except ModuleNotFoundError as error:
59+
# Anything missing deeper than this name is a real dependency error and
60+
# has to keep its own message rather than becoming a typo report.
61+
if error.name != submodule:
62+
raise
63+
raise AttributeError(
64+
f'module {package!r} has no attribute {name!r}'
65+
) from None
4566

4667
def module_dir() -> list[str]:
4768
return sorted(set(module_globals) | set(module_globals.get('__all__', ())))

tests/unittests/test_import_loading.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,20 @@ def test_lazy_packages_support_star_imports():
136136
assert result.returncode == 0, result.stderr
137137

138138

139+
def test_lazy_packages_resolve_subpackages_as_attributes():
140+
"""A subpackage stays reachable on its parent, as eager imports left it."""
141+
result = run_isolated("""
142+
import types
143+
144+
import google.adk
145+
146+
for name in ('agents', 'events', 'runners', 'sessions', 'tools'):
147+
assert isinstance(getattr(google.adk, name), types.ModuleType), name
148+
""")
149+
150+
assert result.returncode == 0, result.stderr
151+
152+
139153
def test_lazy_packages_reject_unknown_attributes():
140154
"""The lazy hook raises AttributeError rather than masking typos."""
141155
result = run_isolated(f"""

tests/unittests/utils/test_lazy.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Tests for the lazy package accessors."""
16+
17+
from __future__ import annotations
18+
19+
import importlib
20+
import types
21+
22+
from google.adk.utils import _lazy
23+
import pytest
24+
25+
26+
@pytest.fixture(name='package')
27+
def _package(tmp_path, monkeypatch) -> types.ModuleType:
28+
"""A package with a plain submodule and one that needs an absent library."""
29+
root = tmp_path / 'lazy_fixture_pkg'
30+
root.mkdir()
31+
(root / '__init__.py').write_text('')
32+
(root / 'plain.py').write_text('VALUE = 1\n')
33+
(root / 'needs_absent.py').write_text('import absent_dependency\n')
34+
(root / 'exports.py').write_text('Exported = object()\n')
35+
monkeypatch.syspath_prepend(str(tmp_path))
36+
return importlib.import_module('lazy_fixture_pkg')
37+
38+
39+
def test_declared_member_resolves_from_its_module(package):
40+
getattr_, _ = _lazy.accessors(vars(package), {'Exported': '.exports'})
41+
42+
exports = importlib.import_module('lazy_fixture_pkg.exports')
43+
assert getattr_('Exported') is exports.Exported
44+
45+
46+
def test_submodule_resolves_as_an_attribute(package):
47+
getattr_, _ = _lazy.accessors(vars(package), {})
48+
49+
assert getattr_('plain').VALUE == 1
50+
51+
52+
def test_unknown_name_raises_attribute_error(package):
53+
getattr_, _ = _lazy.accessors(vars(package), {})
54+
55+
with pytest.raises(AttributeError, match='no attribute'):
56+
getattr_('not_a_submodule')
57+
58+
59+
def test_absent_dependency_keeps_its_own_error(package):
60+
"""A library missing inside a submodule must not read as a typo."""
61+
getattr_, _ = _lazy.accessors(vars(package), {})
62+
63+
with pytest.raises(ModuleNotFoundError) as error:
64+
getattr_('needs_absent')
65+
66+
assert error.value.name == 'absent_dependency'

0 commit comments

Comments
 (0)