Skip to content

Commit 5134c9b

Browse files
GWealecopybara-github
authored andcommitted
test(deps): allowlist what importing Agent and Runner loads
Co-authored-by: George Weale <gweale@google.com> PiperOrigin-RevId: 965334785
1 parent f3fae72 commit 5134c9b

2 files changed

Lines changed: 105 additions & 0 deletions

File tree

tests/unittests/isolated_import_utils.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020

2121
from __future__ import annotations
2222

23+
import json
2324
import os
2425
from pathlib import Path
2526
import subprocess
@@ -54,6 +55,33 @@ def run_isolated(source: str) -> subprocess.CompletedProcess[str]:
5455
)
5556

5657

58+
def loaded_top_level_packages(source: str) -> frozenset[str]:
59+
"""Returns the third-party top-level packages source leaves imported.
60+
61+
Standard-library modules, private modules and the pseudo-modules the
62+
interpreter injects carry no install or startup cost of their own, so they
63+
are dropped and only the distributions a caller pays for remain.
64+
"""
65+
result = run_isolated(f"""
66+
import json
67+
import sys
68+
{source}
69+
70+
names = {{
71+
name.partition('.')[0]
72+
for name, module in sys.modules.items()
73+
if getattr(module, '__spec__', None) is not None
74+
}}
75+
print(json.dumps(sorted(
76+
name
77+
for name in names - sys.stdlib_module_names
78+
if not name.startswith('_')
79+
)))
80+
""")
81+
assert result.returncode == 0, result.stderr
82+
return frozenset(json.loads(result.stdout.splitlines()[-1]))
83+
84+
5785
def assert_modules_unloaded(source: str, forbidden: tuple[str, ...]) -> None:
5886
"""Asserts source leaves every forbidden module (and submodule) unimported."""
5987
result = run_isolated(f"""

tests/unittests/test_import_loading.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626

2727
from . import isolated_import_utils
2828
from .isolated_import_utils import assert_modules_unloaded
29+
from .isolated_import_utils import loaded_top_level_packages
2930
from .isolated_import_utils import run_isolated
3031

3132
pytestmark = pytest.mark.skipif(
@@ -42,6 +43,59 @@
4243
'google.adk.workflow',
4344
)
4445

46+
# The statements almost every ADK program starts with, and therefore the two
47+
# import graphs whose cost every user pays.
48+
_ENTRY_POINTS = (
49+
'from google.adk.agents import Agent',
50+
'from google.adk.runners import Runner',
51+
)
52+
53+
# Third-party top-level packages an entry point may load. The forbidden lists
54+
# above pin individual deferrals on the lazy package inits; this one bounds the
55+
# whole graph, because the cost that reaches users arrives as a package nobody
56+
# noticed rather than as one somebody predicted.
57+
_ENTRY_POINT_PACKAGE_ALLOWLIST = frozenset({
58+
# Declared requirements that ADK imports at module scope.
59+
'click',
60+
'fastapi',
61+
'google',
62+
'httpx',
63+
'opentelemetry',
64+
'packaging',
65+
'pydantic',
66+
'python_multipart',
67+
'starlette',
68+
'tenacity',
69+
'websockets',
70+
# Reached through pydantic and httpx rather than through ADK.
71+
'annotated_doc',
72+
'annotated_types',
73+
'anyio',
74+
'certifi',
75+
'idna',
76+
'orjson',
77+
'pydantic_core',
78+
'pygments',
79+
'rich',
80+
'sniffio',
81+
'typing_extensions',
82+
'typing_inspection',
83+
'zstandard',
84+
# google.genai.types annotates optional fields with aiohttp and Pillow
85+
# types and imports whichever of the two the environment happens to have.
86+
# No ADK module imports either one, so these are absent in some installs.
87+
'PIL',
88+
'aiohappyeyeballs',
89+
'aiohttp',
90+
'aiosignal',
91+
'attr',
92+
'defusedxml',
93+
'frozenlist',
94+
'multidict',
95+
'propcache',
96+
'yarl',
97+
})
98+
4599

46100
@pytest.mark.parametrize(
47101
('module_name', 'forbidden'),
@@ -104,6 +158,29 @@ def test_package_import_defers_unrelated_runtime(
104158
)
105159

106160

161+
@pytest.mark.parametrize('statement', _ENTRY_POINTS, ids=('agent', 'runner'))
162+
def test_entry_point_loads_only_allowlisted_packages(statement: str) -> None:
163+
"""The two entry points every program uses load a reviewed set of packages.
164+
165+
The lazy package inits are already cheap, so a new eager dependency shows up
166+
here first: as a package nobody agreed to pay for on every ADK start.
167+
168+
The unit is the top-level import name, so a new eager dependency arriving
169+
under the `google` namespace, which ADK loads either way, does not show up
170+
here.
171+
"""
172+
unexpected = sorted(
173+
loaded_top_level_packages(statement) - _ENTRY_POINT_PACKAGE_ALLOWLIST
174+
)
175+
176+
assert not unexpected, (
177+
f'{statement!r} now loads {", ".join(unexpected)}, which every ADK'
178+
' process would pay for at startup. Move the import into the function'
179+
' that needs it, or add the package to the allowlist together with the'
180+
' reason it has to be eager.'
181+
)
182+
183+
107184
def test_constructing_agent_defers_optional_mcp_server_stack():
108185
"""A normal Agent does not import MCP just because its extra is installed."""
109186
if importlib.util.find_spec('mcp') is None:

0 commit comments

Comments
 (0)