chore: import upstream snapshot with attribution
Continuous Integration / Pre-commit Linter (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.10) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.11) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.12) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.12) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.14) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.12) (push) Has been cancelled
Copybara PR Handler / close-imported-pr (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.14) (push) Has been cancelled
Continuous Integration / Pre-commit Linter (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.10) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.11) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.12) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.12) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.14) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.12) (push) Has been cancelled
Copybara PR Handler / close-imported-pr (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.14) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,91 @@
|
||||
# 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.
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from google.adk.cli.utils import agent_loader
|
||||
from google.adk.cli.utils.agent_change_handler import AgentChangeEventHandler
|
||||
from google.adk.cli.utils.shared_value import SharedValue
|
||||
import pytest
|
||||
from watchdog.events import FileModifiedEvent
|
||||
|
||||
|
||||
class TestAgentChangeEventHandler:
|
||||
"""Unit tests for AgentChangeEventHandler file extension filtering."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_agent_loader(self):
|
||||
"""Create a mock AgentLoader constrained to the public API."""
|
||||
return mock.create_autospec(
|
||||
agent_loader.AgentLoader, instance=True, spec_set=True
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def handler(self, mock_agent_loader):
|
||||
"""Create an AgentChangeEventHandler with mocked dependencies."""
|
||||
runners_to_clean = set()
|
||||
current_app_name_ref = SharedValue(value="test_agent")
|
||||
return AgentChangeEventHandler(
|
||||
agent_loader=mock_agent_loader,
|
||||
runners_to_clean=runners_to_clean,
|
||||
current_app_name_ref=current_app_name_ref,
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"file_path",
|
||||
[
|
||||
pytest.param("/path/to/agent.py", id="python_file"),
|
||||
pytest.param("/path/to/config.yaml", id="yaml_file"),
|
||||
pytest.param("/path/to/config.yml", id="yml_file"),
|
||||
],
|
||||
)
|
||||
def test_on_modified_triggers_reload_for_supported_extensions(
|
||||
self, handler, mock_agent_loader, file_path
|
||||
):
|
||||
"""Verify that .py, .yaml, and .yml files trigger agent reload."""
|
||||
event = FileModifiedEvent(src_path=file_path)
|
||||
|
||||
handler.on_modified(event)
|
||||
|
||||
mock_agent_loader.remove_agent_from_cache.assert_called_once_with(
|
||||
"test_agent"
|
||||
)
|
||||
assert (
|
||||
"test_agent" in handler.runners_to_clean
|
||||
), f"Expected 'test_agent' in runners_to_clean for {file_path}"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"file_path",
|
||||
[
|
||||
pytest.param("/path/to/file.json", id="json_file"),
|
||||
pytest.param("/path/to/file.txt", id="txt_file"),
|
||||
pytest.param("/path/to/file.md", id="markdown_file"),
|
||||
pytest.param("/path/to/file.toml", id="toml_file"),
|
||||
pytest.param("/path/to/.gitignore", id="gitignore_file"),
|
||||
pytest.param("/path/to/file", id="no_extension"),
|
||||
],
|
||||
)
|
||||
def test_on_modified_ignores_unsupported_extensions(
|
||||
self, handler, mock_agent_loader, file_path
|
||||
):
|
||||
"""Verify that non-py/yaml/yml files do not trigger reload."""
|
||||
event = FileModifiedEvent(src_path=file_path)
|
||||
|
||||
handler.on_modified(event)
|
||||
|
||||
mock_agent_loader.remove_agent_from_cache.assert_not_called()
|
||||
assert not handler.runners_to_clean, (
|
||||
f"Expected runners_to_clean to be empty for {file_path}, "
|
||||
f"got {handler.runners_to_clean}"
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,527 @@
|
||||
# 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.
|
||||
|
||||
"""Unit tests for utilities in cli."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from textwrap import dedent
|
||||
import types
|
||||
from typing import Any
|
||||
from typing import Dict
|
||||
from typing import List
|
||||
from typing import Tuple
|
||||
from unittest import mock
|
||||
|
||||
import click
|
||||
from google.adk.agents.base_agent import BaseAgent
|
||||
from google.adk.apps.app import App
|
||||
from google.adk.artifacts.file_artifact_service import FileArtifactService
|
||||
from google.adk.artifacts.in_memory_artifact_service import InMemoryArtifactService
|
||||
from google.adk.auth.credential_service.in_memory_credential_service import InMemoryCredentialService
|
||||
import google.adk.cli.cli as cli
|
||||
from google.adk.cli.utils.local_storage import PerAgentFileArtifactService
|
||||
from google.adk.cli.utils.service_factory import create_artifact_service_from_options
|
||||
from google.adk.sessions.in_memory_session_service import InMemorySessionService
|
||||
import pytest
|
||||
|
||||
|
||||
# Helpers
|
||||
class _Recorder:
|
||||
"""Callable that records every invocation."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls: List[Tuple[Tuple[Any, ...], Dict[str, Any]]] = []
|
||||
|
||||
def __call__(self, *args: Any, **kwargs: Any) -> None:
|
||||
self.calls.append((args, kwargs))
|
||||
|
||||
|
||||
# Fixtures
|
||||
@pytest.fixture(autouse=True)
|
||||
def _mute_click(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Silence click output in every test."""
|
||||
monkeypatch.setattr(click, "echo", lambda *a, **k: None)
|
||||
monkeypatch.setattr(click, "secho", lambda *a, **k: None)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _patch_types_and_runner(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Replace google.genai.types and Runner with lightweight fakes."""
|
||||
|
||||
# Dummy Part / Content
|
||||
class _Part:
|
||||
|
||||
def __init__(self, text: str | None = "") -> None:
|
||||
self.text = text
|
||||
|
||||
class _Content:
|
||||
|
||||
def __init__(self, role: str, parts: List[_Part]) -> None:
|
||||
self.role = role
|
||||
self.parts = parts
|
||||
|
||||
monkeypatch.setattr(cli.types, "Part", _Part)
|
||||
monkeypatch.setattr(cli.types, "Content", _Content)
|
||||
|
||||
# Fake Runner yielding a single assistant echo
|
||||
class _FakeRunner:
|
||||
|
||||
def __init__(self, *a: Any, **k: Any) -> None:
|
||||
...
|
||||
|
||||
async def run_async(self, *a: Any, **k: Any):
|
||||
message = a[2] if len(a) >= 3 else k["new_message"]
|
||||
text = message.parts[0].text if message.parts else ""
|
||||
response = _Content("assistant", [_Part(f"echo:{text}")])
|
||||
ev = types.SimpleNamespace(
|
||||
author="assistant",
|
||||
content=response,
|
||||
node_info=None,
|
||||
long_running_tool_ids=[],
|
||||
)
|
||||
yield ev
|
||||
|
||||
async def close(self, *a: Any, **k: Any) -> None:
|
||||
...
|
||||
|
||||
monkeypatch.setattr(cli, "Runner", _FakeRunner)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def fake_agent(tmp_path: Path):
|
||||
"""Create a minimal importable agent package and patch importlib."""
|
||||
|
||||
parent_dir = tmp_path / "agents"
|
||||
parent_dir.mkdir()
|
||||
agent_dir = parent_dir / "fake_agent"
|
||||
agent_dir.mkdir()
|
||||
# __init__.py exposes root_agent with .name
|
||||
(agent_dir / "__init__.py").write_text(dedent("""
|
||||
from google.adk.agents.base_agent import BaseAgent
|
||||
class FakeAgent(BaseAgent):
|
||||
def __init__(self, name):
|
||||
super().__init__(name=name)
|
||||
|
||||
root_agent = FakeAgent(name="fake_root")
|
||||
"""))
|
||||
|
||||
return parent_dir, "fake_agent"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def fake_app_agent(tmp_path: Path):
|
||||
"""Create an agent package that exposes an App."""
|
||||
|
||||
parent_dir = tmp_path / "agents"
|
||||
parent_dir.mkdir()
|
||||
agent_dir = parent_dir / "fake_app_agent"
|
||||
agent_dir.mkdir()
|
||||
(agent_dir / "__init__.py").write_text(dedent("""
|
||||
from google.adk.agents.base_agent import BaseAgent
|
||||
from google.adk.apps.app import App
|
||||
class FakeAgent(BaseAgent):
|
||||
def __init__(self, name):
|
||||
super().__init__(name=name)
|
||||
|
||||
root_agent = FakeAgent(name="fake_root")
|
||||
app = App(name="custom_cli_app", root_agent=root_agent)
|
||||
"""))
|
||||
|
||||
return parent_dir, "fake_app_agent", "custom_cli_app"
|
||||
|
||||
|
||||
# _run_input_file
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_input_file_outputs(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""run_input_file should echo user & assistant messages and return a populated session."""
|
||||
recorder: List[str] = []
|
||||
|
||||
def _echo(msg: str) -> None:
|
||||
recorder.append(msg)
|
||||
|
||||
monkeypatch.setattr(click, "echo", _echo)
|
||||
|
||||
input_json = {
|
||||
"state": {"foo": "bar"},
|
||||
"queries": ["hello world"],
|
||||
}
|
||||
input_path = tmp_path / "input.json"
|
||||
input_path.write_text(json.dumps(input_json))
|
||||
|
||||
artifact_service = InMemoryArtifactService()
|
||||
session_service = InMemorySessionService()
|
||||
credential_service = InMemoryCredentialService()
|
||||
dummy_root = BaseAgent(name="root")
|
||||
|
||||
session = await cli.run_input_file(
|
||||
app_name="app",
|
||||
user_id="user",
|
||||
agent_or_app=dummy_root,
|
||||
artifact_service=artifact_service,
|
||||
session_service=session_service,
|
||||
credential_service=credential_service,
|
||||
input_path=str(input_path),
|
||||
)
|
||||
|
||||
assert session.state["foo"] == "bar"
|
||||
assert any("[user]:" in line for line in recorder)
|
||||
assert any("[assistant]:" in line for line in recorder)
|
||||
|
||||
|
||||
# _run_cli (input_file branch)
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_cli_with_input_file(fake_agent, tmp_path: Path) -> None:
|
||||
"""run_cli should process an input file without raising and without saving."""
|
||||
parent_dir, folder_name = fake_agent
|
||||
input_json = {"state": {}, "queries": ["ping"]}
|
||||
input_path = tmp_path / "in.json"
|
||||
input_path.write_text(json.dumps(input_json))
|
||||
|
||||
await cli.run_cli(
|
||||
agent_parent_dir=str(parent_dir),
|
||||
agent_folder_name=folder_name,
|
||||
input_file=str(input_path),
|
||||
saved_session_file=None,
|
||||
save_session=False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_cli_loads_services_module(
|
||||
fake_agent, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""run_cli should load custom services from the agents directory."""
|
||||
parent_dir, folder_name = fake_agent
|
||||
input_json = {"state": {}, "queries": ["ping"]}
|
||||
input_path = tmp_path / "input.json"
|
||||
input_path.write_text(json.dumps(input_json))
|
||||
|
||||
loaded_dirs: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
cli, "load_services_module", lambda path: loaded_dirs.append(path)
|
||||
)
|
||||
|
||||
agent_root = parent_dir / folder_name
|
||||
|
||||
await cli.run_cli(
|
||||
agent_parent_dir=str(parent_dir),
|
||||
agent_folder_name=folder_name,
|
||||
input_file=str(input_path),
|
||||
saved_session_file=None,
|
||||
save_session=False,
|
||||
)
|
||||
|
||||
assert loaded_dirs == [str(agent_root.resolve())]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_cli_app_uses_app_name_for_sessions(
|
||||
fake_app_agent, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""run_cli should honor the App-provided name when creating sessions."""
|
||||
parent_dir, folder_name, app_name = fake_app_agent
|
||||
created_app_names: List[str] = []
|
||||
|
||||
class _SpySessionService(InMemorySessionService):
|
||||
|
||||
async def create_session(self, *, app_name: str, **kwargs: Any) -> Any:
|
||||
created_app_names.append(app_name)
|
||||
return await super().create_session(app_name=app_name, **kwargs)
|
||||
|
||||
spy_session_service = _SpySessionService()
|
||||
|
||||
def _session_factory(**_: Any) -> InMemorySessionService:
|
||||
return spy_session_service
|
||||
|
||||
monkeypatch.setattr(
|
||||
cli, "create_session_service_from_options", _session_factory
|
||||
)
|
||||
|
||||
input_json = {"state": {}, "queries": ["ping"]}
|
||||
input_path = tmp_path / "input_app.json"
|
||||
input_path.write_text(json.dumps(input_json))
|
||||
|
||||
await cli.run_cli(
|
||||
agent_parent_dir=str(parent_dir),
|
||||
agent_folder_name=folder_name,
|
||||
input_file=str(input_path),
|
||||
saved_session_file=None,
|
||||
save_session=False,
|
||||
)
|
||||
|
||||
assert created_app_names
|
||||
assert all(name == app_name for name in created_app_names)
|
||||
|
||||
|
||||
# _run_cli (interactive + save session branch)
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_cli_save_session(
|
||||
fake_agent, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""run_cli should save a session file when save_session=True."""
|
||||
parent_dir, folder_name = fake_agent
|
||||
|
||||
# Simulate user typing 'exit' followed by session id 'sess123'
|
||||
responses = iter(["exit", "sess123"])
|
||||
monkeypatch.setattr("builtins.input", lambda *_a, **_k: next(responses))
|
||||
|
||||
session_file = Path(parent_dir) / folder_name / "sess123.session.json"
|
||||
if session_file.exists():
|
||||
session_file.unlink()
|
||||
|
||||
await cli.run_cli(
|
||||
agent_parent_dir=str(parent_dir),
|
||||
agent_folder_name=folder_name,
|
||||
input_file=None,
|
||||
saved_session_file=None,
|
||||
save_session=True,
|
||||
)
|
||||
|
||||
assert session_file.exists()
|
||||
data = json.loads(session_file.read_text())
|
||||
# The saved JSON should at least contain id and events keys
|
||||
assert "id" in data and "events" in data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_artifact_service_isolates_artifacts_per_agent(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Each agent's artifacts should land in its own .adk/artifacts folder."""
|
||||
(tmp_path / "agent_a").mkdir()
|
||||
(tmp_path / "agent_b").mkdir()
|
||||
service = create_artifact_service_from_options(
|
||||
base_dir=tmp_path,
|
||||
use_local_storage=True,
|
||||
)
|
||||
assert isinstance(service, PerAgentFileArtifactService)
|
||||
|
||||
# Touching each agent provisions its own per-agent .adk/artifacts folder.
|
||||
for app_name in ("agent_a", "agent_b"):
|
||||
await service.list_artifact_keys(
|
||||
app_name=app_name, user_id="user", session_id="session"
|
||||
)
|
||||
|
||||
assert (tmp_path / "agent_a" / ".adk" / "artifacts").exists()
|
||||
assert (tmp_path / "agent_b" / ".adk" / "artifacts").exists()
|
||||
assert not (tmp_path / ".adk").exists()
|
||||
|
||||
|
||||
def test_create_artifact_service_respects_memory_uri(tmp_path: Path) -> None:
|
||||
"""Service factory should honor memory:// URIs."""
|
||||
service = create_artifact_service_from_options(
|
||||
base_dir=tmp_path, artifact_service_uri="memory://"
|
||||
)
|
||||
assert isinstance(service, InMemoryArtifactService)
|
||||
|
||||
|
||||
def test_create_artifact_service_accepts_file_uri(tmp_path: Path) -> None:
|
||||
"""Service factory should allow custom local roots via file:// URIs."""
|
||||
custom_root = tmp_path / "custom_artifacts"
|
||||
service = create_artifact_service_from_options(
|
||||
base_dir=tmp_path, artifact_service_uri=custom_root.as_uri()
|
||||
)
|
||||
assert isinstance(service, FileArtifactService)
|
||||
assert service.root_dir == custom_root
|
||||
assert custom_root.exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_cli_accepts_memory_scheme(
|
||||
fake_agent, tmp_path: Path
|
||||
) -> None:
|
||||
"""run_cli should allow configuring in-memory services via memory:// URIs."""
|
||||
parent_dir, folder_name = fake_agent
|
||||
input_json = {"state": {}, "queries": []}
|
||||
input_path = tmp_path / "noop.json"
|
||||
input_path.write_text(json.dumps(input_json))
|
||||
|
||||
await cli.run_cli(
|
||||
agent_parent_dir=str(parent_dir),
|
||||
agent_folder_name=folder_name,
|
||||
input_file=str(input_path),
|
||||
saved_session_file=None,
|
||||
save_session=False,
|
||||
session_service_uri="memory://",
|
||||
artifact_service_uri="memory://",
|
||||
memory_service_uri="memory://",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_cli_invalid_memory_uri_surfaces_value_error(
|
||||
fake_agent, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""run_cli should let ValueError propagate for invalid memory service URIs."""
|
||||
parent_dir, folder_name = fake_agent
|
||||
input_json = {"state": {}, "queries": []}
|
||||
input_path = tmp_path / "invalid_memory_uri.json"
|
||||
input_path.write_text(json.dumps(input_json))
|
||||
|
||||
def _raise_invalid_memory_uri(
|
||||
*,
|
||||
base_dir: Path | str,
|
||||
memory_service_uri: str | None = None,
|
||||
) -> object:
|
||||
del base_dir, memory_service_uri
|
||||
raise ValueError("Unsupported memory service URI: unknown://x")
|
||||
|
||||
monkeypatch.setattr(
|
||||
cli, "create_memory_service_from_options", _raise_invalid_memory_uri
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Unsupported memory service URI"):
|
||||
await cli.run_cli(
|
||||
agent_parent_dir=str(parent_dir),
|
||||
agent_folder_name=folder_name,
|
||||
input_file=str(input_path),
|
||||
saved_session_file=None,
|
||||
save_session=False,
|
||||
memory_service_uri="unknown://x",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_cli_passes_memory_service_to_input_file(
|
||||
fake_agent, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""run_cli should construct and pass the configured memory service."""
|
||||
parent_dir, folder_name = fake_agent
|
||||
input_json = {"state": {}, "queries": []}
|
||||
input_path = tmp_path / "memory_input.json"
|
||||
input_path.write_text(json.dumps(input_json))
|
||||
|
||||
memory_service_sentinel = object()
|
||||
captured_factory_args: dict[str, Any] = {}
|
||||
captured_memory_service: dict[str, Any] = {}
|
||||
|
||||
def _memory_factory(
|
||||
*,
|
||||
base_dir: Path | str,
|
||||
memory_service_uri: str | None = None,
|
||||
) -> object:
|
||||
captured_factory_args["base_dir"] = base_dir
|
||||
captured_factory_args["memory_service_uri"] = memory_service_uri
|
||||
return memory_service_sentinel
|
||||
|
||||
async def _run_input_file(
|
||||
app_name: str,
|
||||
user_id: str,
|
||||
agent_or_app: BaseAgent | App,
|
||||
artifact_service: Any,
|
||||
session_service: Any,
|
||||
credential_service: InMemoryCredentialService,
|
||||
input_path: str,
|
||||
memory_service: Any = None,
|
||||
) -> object:
|
||||
del app_name, user_id, agent_or_app, artifact_service
|
||||
del session_service, credential_service, input_path
|
||||
captured_memory_service["value"] = memory_service
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr(
|
||||
cli, "create_memory_service_from_options", _memory_factory
|
||||
)
|
||||
monkeypatch.setattr(cli, "run_input_file", _run_input_file)
|
||||
|
||||
await cli.run_cli(
|
||||
agent_parent_dir=str(parent_dir),
|
||||
agent_folder_name=folder_name,
|
||||
input_file=str(input_path),
|
||||
saved_session_file=None,
|
||||
save_session=False,
|
||||
memory_service_uri="memory://",
|
||||
)
|
||||
|
||||
assert Path(captured_factory_args["base_dir"]) == parent_dir.resolve()
|
||||
assert captured_factory_args["memory_service_uri"] == "memory://"
|
||||
assert captured_memory_service["value"] is memory_service_sentinel
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_cli_loads_dotenv_before_memory_service_creation(
|
||||
fake_agent, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""run_cli should load agent .env values before creating memory service."""
|
||||
parent_dir, folder_name = fake_agent
|
||||
input_json = {"state": {}, "queries": []}
|
||||
input_path = tmp_path / "dotenv_order_input.json"
|
||||
input_path.write_text(json.dumps(input_json))
|
||||
|
||||
call_order: list[str] = []
|
||||
|
||||
def _load_dotenv_for_agent(agent_name: str, agents_dir: str) -> None:
|
||||
del agent_name, agents_dir
|
||||
call_order.append("load_dotenv")
|
||||
|
||||
def _memory_factory(
|
||||
*,
|
||||
base_dir: Path | str,
|
||||
memory_service_uri: str | None = None,
|
||||
) -> object:
|
||||
del base_dir, memory_service_uri
|
||||
call_order.append("create_memory")
|
||||
return object()
|
||||
|
||||
monkeypatch.setenv("ADK_DISABLE_LOAD_DOTENV", "0")
|
||||
monkeypatch.setattr(cli.envs, "load_dotenv_for_agent", _load_dotenv_for_agent)
|
||||
monkeypatch.setattr(
|
||||
cli, "create_memory_service_from_options", _memory_factory
|
||||
)
|
||||
|
||||
await cli.run_cli(
|
||||
agent_parent_dir=str(parent_dir),
|
||||
agent_folder_name=folder_name,
|
||||
input_file=str(input_path),
|
||||
saved_session_file=None,
|
||||
save_session=False,
|
||||
memory_service_uri="memory://",
|
||||
)
|
||||
|
||||
assert "create_memory" in call_order
|
||||
assert "load_dotenv" in call_order
|
||||
assert call_order.index("load_dotenv") < call_order.index("create_memory")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_interactively_whitespace_and_exit(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""run_interactively should skip blank input, echo once, then exit."""
|
||||
# make a session that belongs to dummy agent
|
||||
session_service = InMemorySessionService()
|
||||
sess = await session_service.create_session(app_name="dummy", user_id="u")
|
||||
artifact_service = InMemoryArtifactService()
|
||||
credential_service = InMemoryCredentialService()
|
||||
root_agent = BaseAgent(name="root")
|
||||
|
||||
# fake user input: blank -> 'hello' -> 'exit'
|
||||
answers = iter([" ", "hello", "exit"])
|
||||
monkeypatch.setattr("builtins.input", lambda *_a, **_k: next(answers))
|
||||
|
||||
# capture assisted echo
|
||||
echoed: list[str] = []
|
||||
monkeypatch.setattr(click, "echo", lambda msg: echoed.append(msg))
|
||||
|
||||
await cli.run_interactively(
|
||||
root_agent, artifact_service, sess, session_service, credential_service
|
||||
)
|
||||
|
||||
# verify: assistant echoed once with 'echo:hello'
|
||||
assert any("echo:hello" in m for m in echoed)
|
||||
@@ -0,0 +1,560 @@
|
||||
# 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 utilities in cli_create."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
from typing import Any
|
||||
from typing import Dict
|
||||
from typing import List
|
||||
from typing import Tuple
|
||||
|
||||
import click
|
||||
import google.adk.cli.cli_create as cli_create
|
||||
from google.adk.cli.utils import _onboarding
|
||||
from google.adk.cli.utils import gcp_utils
|
||||
import pytest
|
||||
|
||||
|
||||
# Helpers
|
||||
class _Recorder:
|
||||
"""A callable object that records every invocation."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls: List[Tuple[Tuple[Any, ...], Dict[str, Any]]] = []
|
||||
|
||||
def __call__(self, *args: Any, **kwargs: Any) -> None: # noqa: D401
|
||||
self.calls.append((args, kwargs))
|
||||
|
||||
|
||||
# Fixtures
|
||||
@pytest.fixture(autouse=True)
|
||||
def _mute_click(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Silence click output in every test."""
|
||||
monkeypatch.setattr(click, "echo", lambda *a, **k: None)
|
||||
monkeypatch.setattr(click, "secho", lambda *a, **k: None)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def agent_folder(tmp_path: Path) -> Path:
|
||||
"""Return a temporary path that will hold generated agent sources."""
|
||||
return tmp_path / "agent"
|
||||
|
||||
|
||||
# _generate_files
|
||||
def test_generate_files_with_api_key(agent_folder: Path) -> None:
|
||||
"""Files should be created with the API-key backend and correct .env flags."""
|
||||
cli_create._generate_files(
|
||||
str(agent_folder),
|
||||
google_api_key="dummy-key",
|
||||
model="gemini-2.5-flash",
|
||||
type="code",
|
||||
)
|
||||
|
||||
env_content = (agent_folder / ".env").read_text()
|
||||
assert "GOOGLE_API_KEY=dummy-key" in env_content
|
||||
assert "GOOGLE_GENAI_USE_ENTERPRISE=0" in env_content
|
||||
assert (agent_folder / ".gitignore").read_text() == ".env\n"
|
||||
assert (agent_folder / "agent.py").exists()
|
||||
assert (agent_folder / "__init__.py").exists()
|
||||
|
||||
|
||||
def test_generate_files_with_gcp(agent_folder: Path) -> None:
|
||||
"""Files should be created with Vertex AI backend and correct .env flags."""
|
||||
cli_create._generate_files(
|
||||
str(agent_folder),
|
||||
google_cloud_project="proj",
|
||||
google_cloud_region="us-central1",
|
||||
model="gemini-2.5-flash",
|
||||
type="code",
|
||||
)
|
||||
|
||||
env_content = (agent_folder / ".env").read_text()
|
||||
assert "GOOGLE_CLOUD_PROJECT=proj" in env_content
|
||||
assert "GOOGLE_CLOUD_LOCATION=us-central1" in env_content
|
||||
assert "GOOGLE_GENAI_USE_ENTERPRISE=1" in env_content
|
||||
|
||||
|
||||
def test_generate_files_with_express_mode(agent_folder: Path) -> None:
|
||||
"""Files should be created with Vertex AI backend when both project and API key are present (Express Mode)."""
|
||||
cli_create._generate_files(
|
||||
str(agent_folder),
|
||||
google_api_key="express-api-key",
|
||||
google_cloud_project="express-project-id",
|
||||
google_cloud_region="us-central1",
|
||||
model="gemini-2.5-flash",
|
||||
type="code",
|
||||
)
|
||||
|
||||
env_content = (agent_folder / ".env").read_text()
|
||||
assert "GOOGLE_GENAI_USE_ENTERPRISE=1" in env_content
|
||||
assert "GOOGLE_API_KEY=express-api-key" in env_content
|
||||
assert "GOOGLE_CLOUD_PROJECT=express-project-id" in env_content
|
||||
|
||||
|
||||
def test_generate_files_overwrite(agent_folder: Path) -> None:
|
||||
"""Existing files should be overwritten when generating again."""
|
||||
agent_folder.mkdir(parents=True, exist_ok=True)
|
||||
(agent_folder / ".env").write_text("OLD")
|
||||
|
||||
cli_create._generate_files(
|
||||
str(agent_folder),
|
||||
google_api_key="new-key",
|
||||
model="gemini-2.5-flash",
|
||||
type="code",
|
||||
)
|
||||
|
||||
assert "GOOGLE_API_KEY=new-key" in (agent_folder / ".env").read_text()
|
||||
|
||||
|
||||
def test_generate_files_permission_error(
|
||||
monkeypatch: pytest.MonkeyPatch, agent_folder: Path
|
||||
) -> None:
|
||||
"""PermissionError raised by os.makedirs should propagate."""
|
||||
monkeypatch.setattr(
|
||||
os, "makedirs", lambda *a, **k: (_ for _ in ()).throw(PermissionError())
|
||||
)
|
||||
with pytest.raises(PermissionError):
|
||||
cli_create._generate_files(
|
||||
str(agent_folder), model="gemini-2.5-flash", type="code"
|
||||
)
|
||||
|
||||
|
||||
def test_generate_files_no_params(agent_folder: Path) -> None:
|
||||
"""No backend parameters → minimal .env file is generated."""
|
||||
cli_create._generate_files(
|
||||
str(agent_folder), model="gemini-2.5-flash", type="code"
|
||||
)
|
||||
|
||||
env_content = (agent_folder / ".env").read_text()
|
||||
for key in (
|
||||
"GOOGLE_API_KEY",
|
||||
"GOOGLE_CLOUD_PROJECT",
|
||||
"GOOGLE_CLOUD_LOCATION",
|
||||
"GOOGLE_GENAI_USE_ENTERPRISE",
|
||||
):
|
||||
assert key not in env_content
|
||||
|
||||
|
||||
def test_generate_files_appends_dotenv_to_existing_gitignore(
|
||||
agent_folder: Path,
|
||||
) -> None:
|
||||
"""Existing .gitignore entries should be preserved."""
|
||||
agent_folder.mkdir(parents=True, exist_ok=True)
|
||||
(agent_folder / ".gitignore").write_text("__pycache__")
|
||||
|
||||
cli_create._generate_files(
|
||||
str(agent_folder), model="gemini-2.0-flash-001", type="code"
|
||||
)
|
||||
|
||||
assert (agent_folder / ".gitignore").read_text() == "__pycache__\n.env\n"
|
||||
|
||||
|
||||
def test_generate_files_appends_dotenv_to_existing_gitignore_with_newline(
|
||||
agent_folder: Path,
|
||||
) -> None:
|
||||
"""Existing .gitignore entries ending in a newline should not cause extra blank lines."""
|
||||
agent_folder.mkdir(parents=True, exist_ok=True)
|
||||
(agent_folder / ".gitignore").write_text("__pycache__\n")
|
||||
|
||||
cli_create._generate_files(
|
||||
str(agent_folder), model="gemini-2.0-flash-001", type="code"
|
||||
)
|
||||
|
||||
assert (agent_folder / ".gitignore").read_text() == "__pycache__\n.env\n"
|
||||
|
||||
|
||||
def test_generate_files_does_not_duplicate_dotenv_gitignore_entry(
|
||||
agent_folder: Path,
|
||||
) -> None:
|
||||
"""Existing .env ignore entries should not be duplicated."""
|
||||
agent_folder.mkdir(parents=True, exist_ok=True)
|
||||
(agent_folder / ".gitignore").write_text("__pycache__\n.env\n")
|
||||
|
||||
cli_create._generate_files(
|
||||
str(agent_folder), model="gemini-2.0-flash-001", type="code"
|
||||
)
|
||||
|
||||
assert (agent_folder / ".gitignore").read_text() == "__pycache__\n.env\n"
|
||||
|
||||
|
||||
# run_cmd
|
||||
def test_run_cmd_overwrite_reject(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
"""User rejecting overwrite should trigger click.Abort."""
|
||||
agent_name = "agent"
|
||||
agent_dir = tmp_path / agent_name
|
||||
agent_dir.mkdir()
|
||||
(agent_dir / "dummy.txt").write_text("dummy")
|
||||
|
||||
monkeypatch.setattr(os, "getcwd", lambda: str(tmp_path))
|
||||
monkeypatch.setattr(click, "confirm", lambda *a, **k: False)
|
||||
|
||||
with pytest.raises(click.Abort):
|
||||
cli_create.run_cmd(
|
||||
agent_name,
|
||||
model="gemini-2.5-flash",
|
||||
google_api_key=None,
|
||||
google_cloud_project=None,
|
||||
google_cloud_region=None,
|
||||
type="code",
|
||||
)
|
||||
|
||||
|
||||
def test_run_cmd_invalid_app_name(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
"""Invalid app names should be rejected before creating any files."""
|
||||
monkeypatch.setattr(os, "getcwd", lambda: str(tmp_path))
|
||||
|
||||
with pytest.raises(click.BadParameter, match="Invalid app name"):
|
||||
cli_create.run_cmd(
|
||||
"invalid name",
|
||||
model="gemini-2.5-flash",
|
||||
google_api_key=None,
|
||||
google_cloud_project=None,
|
||||
google_cloud_region=None,
|
||||
type="code",
|
||||
)
|
||||
|
||||
|
||||
def test_run_cmd_with_type_config(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
"""run_cmd with --type=config should generate YAML config file."""
|
||||
agent_name = "test_agent"
|
||||
|
||||
monkeypatch.setattr(os, "getcwd", lambda: str(tmp_path))
|
||||
|
||||
cli_create.run_cmd(
|
||||
agent_name,
|
||||
model="gemini-2.5-flash",
|
||||
google_api_key="test-key",
|
||||
google_cloud_project=None,
|
||||
google_cloud_region=None,
|
||||
type="config",
|
||||
)
|
||||
|
||||
agent_dir = tmp_path / agent_name
|
||||
assert agent_dir.exists()
|
||||
|
||||
# Should create root_agent.yaml instead of agent.py
|
||||
yaml_file = agent_dir / "root_agent.yaml"
|
||||
assert yaml_file.exists()
|
||||
assert not (agent_dir / "agent.py").exists()
|
||||
|
||||
# Check YAML content
|
||||
yaml_content = yaml_file.read_text()
|
||||
assert "name: root_agent" in yaml_content
|
||||
assert "model: gemini-2.5-flash" in yaml_content
|
||||
assert "description: A helpful assistant for user questions." in yaml_content
|
||||
|
||||
# Should create empty __init__.py
|
||||
init_file = agent_dir / "__init__.py"
|
||||
assert init_file.exists()
|
||||
assert init_file.read_text().strip() == ""
|
||||
|
||||
# Should still create .env file
|
||||
env_file = agent_dir / ".env"
|
||||
assert env_file.exists()
|
||||
assert "GOOGLE_API_KEY=test-key" in env_file.read_text()
|
||||
assert (agent_dir / ".gitignore").read_text() == ".env\n"
|
||||
|
||||
|
||||
# Prompt helpers
|
||||
def test_prompt_for_google_cloud(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Prompt should return the project input."""
|
||||
monkeypatch.setattr(click, "prompt", lambda *a, **k: "test-proj")
|
||||
assert _onboarding.prompt_for_google_cloud(None) == "test-proj"
|
||||
|
||||
|
||||
def test_prompt_for_google_cloud_region(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Prompt should return the region input."""
|
||||
monkeypatch.setattr(click, "prompt", lambda *a, **k: "asia-northeast1")
|
||||
assert _onboarding.prompt_for_google_cloud_region(None) == "asia-northeast1"
|
||||
|
||||
|
||||
def test_prompt_for_google_api_key(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Prompt should return the API-key input."""
|
||||
monkeypatch.setattr(click, "prompt", lambda *a, **k: "api-key")
|
||||
assert _onboarding.prompt_for_google_api_key(None) == "api-key"
|
||||
|
||||
|
||||
def test_prompt_for_model_gemini(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Selecting option '1' should return the default Gemini model string."""
|
||||
monkeypatch.setattr(click, "prompt", lambda *a, **k: "1")
|
||||
assert cli_create._prompt_for_model() == "gemini-3.5-flash"
|
||||
|
||||
|
||||
def test_prompt_for_model_other(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Selecting option '2' should return placeholder and call secho."""
|
||||
called: Dict[str, bool] = {}
|
||||
|
||||
monkeypatch.setattr(click, "prompt", lambda *a, **k: "2")
|
||||
|
||||
def _fake_secho(*_a: Any, **_k: Any) -> None:
|
||||
called["secho"] = True
|
||||
|
||||
monkeypatch.setattr(click, "secho", _fake_secho)
|
||||
assert cli_create._prompt_for_model() == "<FILL_IN_MODEL>"
|
||||
assert called.get("secho") is True
|
||||
|
||||
|
||||
# Backend selection helper
|
||||
def test_prompt_to_choose_backend_api(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Choosing API-key backend returns (api_key, None, None)."""
|
||||
monkeypatch.setattr(click, "prompt", lambda *a, **k: "1")
|
||||
monkeypatch.setattr(
|
||||
_onboarding, "prompt_for_google_api_key", lambda _v: "api-key"
|
||||
)
|
||||
|
||||
auth_info = _onboarding.prompt_to_choose_backend(None, None, None)
|
||||
assert isinstance(auth_info, _onboarding.GoogleAIAuth)
|
||||
assert auth_info.api_key == "api-key"
|
||||
|
||||
|
||||
def test_prompt_to_choose_backend_vertex(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Choosing Vertex backend returns (None, project, region)."""
|
||||
monkeypatch.setattr(click, "prompt", lambda *a, **k: "2")
|
||||
monkeypatch.setattr(_onboarding, "prompt_for_google_cloud", lambda _v: "proj")
|
||||
monkeypatch.setattr(
|
||||
_onboarding, "prompt_for_google_cloud_region", lambda _v: "region"
|
||||
)
|
||||
|
||||
auth_info = _onboarding.prompt_to_choose_backend(None, None, None)
|
||||
assert isinstance(auth_info, _onboarding.VertexAIAuth)
|
||||
assert auth_info.project_id == "proj"
|
||||
assert auth_info.region == "region"
|
||||
|
||||
|
||||
def test_prompt_to_choose_backend_login(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Choosing Login with Google returns (api_key, project, region) from handler."""
|
||||
monkeypatch.setattr(click, "prompt", lambda *a, **k: "3")
|
||||
monkeypatch.setattr(
|
||||
_onboarding,
|
||||
"handle_login_with_google",
|
||||
lambda: _onboarding.ExpressModeAuth(
|
||||
api_key="api-key", project_id="proj", region="region"
|
||||
),
|
||||
)
|
||||
|
||||
auth_info = _onboarding.prompt_to_choose_backend(None, None, None)
|
||||
assert isinstance(auth_info, _onboarding.ExpressModeAuth)
|
||||
assert auth_info.api_key == "api-key"
|
||||
assert auth_info.project_id == "proj"
|
||||
assert auth_info.region == "region"
|
||||
|
||||
|
||||
def test_handle_login_with_google_existing_express(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Handler should return existing Express project if found."""
|
||||
monkeypatch.setattr(gcp_utils, "check_adc", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
gcp_utils,
|
||||
"retrieve_express_project",
|
||||
lambda: {"api_key": "key", "project_id": "proj", "region": "us-central1"},
|
||||
)
|
||||
|
||||
auth_info = _onboarding.handle_login_with_google()
|
||||
assert isinstance(auth_info, _onboarding.ExpressModeAuth)
|
||||
assert auth_info.api_key == "key"
|
||||
assert auth_info.project_id == "proj"
|
||||
assert auth_info.region == "us-central1"
|
||||
|
||||
|
||||
def test_handle_login_with_google_select_gcp_project(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Handler should prompt for project selection if no Express project found."""
|
||||
monkeypatch.setattr(gcp_utils, "check_adc", lambda: True)
|
||||
monkeypatch.setattr(gcp_utils, "retrieve_express_project", lambda: None)
|
||||
monkeypatch.setattr(
|
||||
gcp_utils, "list_gcp_projects", lambda limit: [("p1", "Project 1")]
|
||||
)
|
||||
monkeypatch.setattr(click, "prompt", lambda *a, **k: 1)
|
||||
monkeypatch.setattr(
|
||||
_onboarding, "prompt_for_google_cloud_region", lambda _v: "us-east1"
|
||||
)
|
||||
|
||||
auth_info = _onboarding.handle_login_with_google()
|
||||
assert isinstance(auth_info, _onboarding.VertexAIAuth)
|
||||
assert auth_info.project_id == "p1"
|
||||
assert auth_info.region == "us-east1"
|
||||
|
||||
|
||||
def test_handle_login_with_google_manual_project(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Handler should allow manual project ID entry when '0' is selected."""
|
||||
monkeypatch.setattr(gcp_utils, "check_adc", lambda: True)
|
||||
monkeypatch.setattr(gcp_utils, "retrieve_express_project", lambda: None)
|
||||
monkeypatch.setattr(
|
||||
gcp_utils, "list_gcp_projects", lambda limit: [("p1", "Project 1")]
|
||||
)
|
||||
prompts = iter([0, "manual-proj", "us-east1"])
|
||||
monkeypatch.setattr(click, "prompt", lambda *a, **k: next(prompts))
|
||||
|
||||
auth_info = _onboarding.handle_login_with_google()
|
||||
assert isinstance(auth_info, _onboarding.VertexAIAuth)
|
||||
assert auth_info.project_id == "manual-proj"
|
||||
assert auth_info.region == "us-east1"
|
||||
|
||||
|
||||
def test_handle_login_with_google_option_1(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""User selects 1, enters project ID and region."""
|
||||
monkeypatch.setattr(gcp_utils, "check_adc", lambda: True)
|
||||
monkeypatch.setattr(gcp_utils, "retrieve_express_project", lambda: None)
|
||||
monkeypatch.setattr(gcp_utils, "list_gcp_projects", lambda limit: [])
|
||||
prompts = iter(["1", "test-proj", "us-east1"])
|
||||
monkeypatch.setattr(click, "prompt", lambda *a, **k: next(prompts))
|
||||
|
||||
auth_info = _onboarding.handle_login_with_google()
|
||||
assert isinstance(auth_info, _onboarding.VertexAIAuth)
|
||||
assert auth_info.project_id == "test-proj"
|
||||
assert auth_info.region == "us-east1"
|
||||
|
||||
|
||||
def test_handle_login_with_google_option_2(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""User selects 2, goes through express sign up."""
|
||||
monkeypatch.setattr(gcp_utils, "check_adc", lambda: True)
|
||||
monkeypatch.setattr(gcp_utils, "retrieve_express_project", lambda: None)
|
||||
monkeypatch.setattr(gcp_utils, "list_gcp_projects", lambda limit: [])
|
||||
monkeypatch.setattr(gcp_utils, "check_express_eligibility", lambda: True)
|
||||
monkeypatch.setattr(click, "confirm", lambda *a, **k: True)
|
||||
prompts = iter(["2", "1"])
|
||||
monkeypatch.setattr(click, "prompt", lambda *a, **k: next(prompts))
|
||||
monkeypatch.setattr(
|
||||
gcp_utils,
|
||||
"sign_up_express",
|
||||
lambda location="us-central1": {
|
||||
"api_key": "new-key",
|
||||
"project_id": "new-proj",
|
||||
"region": location,
|
||||
},
|
||||
)
|
||||
|
||||
auth_info = _onboarding.handle_login_with_google()
|
||||
assert isinstance(auth_info, _onboarding.ExpressModeAuth)
|
||||
assert auth_info.api_key == "new-key"
|
||||
assert auth_info.project_id == "new-proj"
|
||||
assert auth_info.region == "us-central1"
|
||||
|
||||
|
||||
def test_handle_login_with_google_option_2_unset_project(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""User selects 2, goes through express sign up, and unsets existing gcloud project."""
|
||||
monkeypatch.setattr(gcp_utils, "check_adc", lambda: True)
|
||||
monkeypatch.setattr(gcp_utils, "retrieve_express_project", lambda: None)
|
||||
monkeypatch.setattr(gcp_utils, "list_gcp_projects", lambda limit: [])
|
||||
monkeypatch.setattr(gcp_utils, "check_express_eligibility", lambda: True)
|
||||
|
||||
confirms = iter([True, True])
|
||||
monkeypatch.setattr(click, "confirm", lambda *a, **k: next(confirms))
|
||||
|
||||
prompts = iter(["2", "1"])
|
||||
monkeypatch.setattr(click, "prompt", lambda *a, **k: next(prompts))
|
||||
|
||||
monkeypatch.setattr(
|
||||
gcp_utils,
|
||||
"sign_up_express",
|
||||
lambda location="us-central1": {
|
||||
"api_key": "new-key",
|
||||
"project_id": "new-proj",
|
||||
"region": location,
|
||||
},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
_onboarding, "get_gcp_project_from_gcloud", lambda: "old-proj"
|
||||
)
|
||||
|
||||
called = {}
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
if cmd == ["gcloud", "config", "unset", "project"]:
|
||||
called["unset"] = True
|
||||
return subprocess.CompletedProcess(args=cmd, returncode=0)
|
||||
raise ValueError(f"Unexpected command: {cmd}")
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
|
||||
auth_info = _onboarding.handle_login_with_google()
|
||||
assert isinstance(auth_info, _onboarding.ExpressModeAuth)
|
||||
assert auth_info.api_key == "new-key"
|
||||
assert auth_info.project_id == "new-proj"
|
||||
assert auth_info.region == "us-central1"
|
||||
assert called.get("unset") is True
|
||||
|
||||
|
||||
def test_handle_login_with_google_option_3(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""User selects 3, aborts."""
|
||||
monkeypatch.setattr(gcp_utils, "retrieve_express_project", lambda: None)
|
||||
monkeypatch.setattr(gcp_utils, "list_gcp_projects", lambda limit: [])
|
||||
monkeypatch.setattr(click, "prompt", lambda *a, **k: "3")
|
||||
with pytest.raises(click.Abort):
|
||||
_onboarding.handle_login_with_google()
|
||||
|
||||
|
||||
# prompt_str
|
||||
def test_prompt_str_non_empty(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""prompt_str should retry until a non-blank string is provided."""
|
||||
responses = iter(["", " ", "valid"])
|
||||
monkeypatch.setattr(click, "prompt", lambda *_a, **_k: next(responses))
|
||||
assert _onboarding.prompt_str("dummy") == "valid"
|
||||
|
||||
|
||||
# gcloud fallback helpers
|
||||
def test_get_gcp_project_from_gcloud_fail(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Failure of gcloud project lookup should return empty string."""
|
||||
monkeypatch.setattr(
|
||||
subprocess,
|
||||
"run",
|
||||
lambda *_a, **_k: (_ for _ in ()).throw(FileNotFoundError()),
|
||||
)
|
||||
assert _onboarding.get_gcp_project_from_gcloud() == ""
|
||||
|
||||
|
||||
def test_get_gcp_region_from_gcloud_fail(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""CalledProcessError should result in empty region string."""
|
||||
monkeypatch.setattr(
|
||||
subprocess,
|
||||
"run",
|
||||
lambda *_a, **_k: (_ for _ in ()).throw(
|
||||
subprocess.CalledProcessError(1, "gcloud")
|
||||
),
|
||||
)
|
||||
assert _onboarding.get_gcp_region_from_gcloud() == ""
|
||||
@@ -0,0 +1,714 @@
|
||||
# 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 utilities in cli_deploy."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import types
|
||||
from typing import Any
|
||||
from typing import Callable
|
||||
from typing import Dict
|
||||
from typing import List
|
||||
from typing import Tuple
|
||||
from unittest import mock
|
||||
|
||||
import click
|
||||
from click.testing import CliRunner
|
||||
import pytest
|
||||
|
||||
import src.google.adk.cli.cli_deploy as cli_deploy
|
||||
import src.google.adk.cli.cli_tools_click as cli_tools_click
|
||||
|
||||
|
||||
# Helpers
|
||||
class _Recorder:
|
||||
"""A callable object that records every invocation."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls: List[Tuple[Tuple[Any, ...], Dict[str, Any]]] = []
|
||||
|
||||
def __call__(self, *args: Any, **kwargs: Any) -> None:
|
||||
self.calls.append((args, kwargs))
|
||||
|
||||
def get_last_call_args(self) -> Tuple[Any, ...]:
|
||||
"""Returns the positional arguments of the last call."""
|
||||
if not self.calls:
|
||||
raise IndexError("No calls have been recorded.")
|
||||
return self.calls[-1][0]
|
||||
|
||||
def get_last_call_kwargs(self) -> Dict[str, Any]:
|
||||
"""Returns the keyword arguments of the last call."""
|
||||
if not self.calls:
|
||||
raise IndexError("No calls have been recorded.")
|
||||
return self.calls[-1][1]
|
||||
|
||||
|
||||
# Fixtures
|
||||
@pytest.fixture(autouse=True)
|
||||
def _mute_click(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Suppress click.echo to keep test output clean."""
|
||||
monkeypatch.setattr(click, "echo", lambda *a, **k: None)
|
||||
monkeypatch.setattr(click, "secho", lambda *a, **k: None)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reload_cli_deploy():
|
||||
"""Reload cli_deploy before each test."""
|
||||
importlib.reload(cli_deploy)
|
||||
yield # This allows the test to run after the module has been reloaded.
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def agent_dir(tmp_path: Path) -> Callable[[bool, bool], Path]:
|
||||
"""
|
||||
Return a factory that creates a dummy agent directory tree.
|
||||
"""
|
||||
|
||||
def _factory(include_requirements: bool, include_env: bool) -> Path:
|
||||
base = tmp_path / "agent"
|
||||
base.mkdir()
|
||||
(base / "agent.py").write_text(
|
||||
"# dummy agent\nroot_agent = 'dummy_agent'\n"
|
||||
)
|
||||
(base / "__init__.py").touch()
|
||||
if include_requirements:
|
||||
(base / "requirements.txt").write_text("pytest\n")
|
||||
if include_env:
|
||||
(base / ".env").write_text('TEST_VAR="test_value"\n')
|
||||
return base
|
||||
|
||||
return _factory
|
||||
|
||||
|
||||
# _resolve_project
|
||||
def test_resolve_project_with_option() -> None:
|
||||
"""It should return the explicit project value untouched."""
|
||||
assert cli_deploy._resolve_project("my-project") == "my-project"
|
||||
|
||||
|
||||
def test_resolve_project_from_gcloud(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""It should fall back to `gcloud config get-value project` when no value supplied."""
|
||||
monkeypatch.setattr(
|
||||
subprocess,
|
||||
"run",
|
||||
lambda *a, **k: types.SimpleNamespace(stdout="gcp-proj\n"),
|
||||
)
|
||||
|
||||
with mock.patch("click.echo") as mocked_echo:
|
||||
assert cli_deploy._resolve_project(None) == "gcp-proj"
|
||||
mocked_echo.assert_called_once()
|
||||
|
||||
|
||||
def test_resolve_project_from_gcloud_fails(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""It should raise an exception if the gcloud command fails."""
|
||||
monkeypatch.setattr(
|
||||
subprocess,
|
||||
"run",
|
||||
mock.Mock(side_effect=subprocess.CalledProcessError(1, "cmd", "err")),
|
||||
)
|
||||
with pytest.raises(subprocess.CalledProcessError):
|
||||
cli_deploy._resolve_project(None)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"adk_version, session_uri, artifact_uri, memory_uri, use_local_storage, "
|
||||
"expected",
|
||||
[
|
||||
(
|
||||
"1.3.0",
|
||||
"sqlite://s",
|
||||
"gs://a",
|
||||
"rag://m",
|
||||
None,
|
||||
(
|
||||
"--session_service_uri=sqlite://s --artifact_service_uri=gs://a"
|
||||
" --memory_service_uri=rag://m"
|
||||
),
|
||||
),
|
||||
(
|
||||
"1.2.5",
|
||||
"sqlite://s",
|
||||
"gs://a",
|
||||
"rag://m",
|
||||
None,
|
||||
(
|
||||
"--session_service_uri=sqlite://s --artifact_service_uri=gs://a"
|
||||
" --memory_service_uri=rag://m"
|
||||
),
|
||||
),
|
||||
(
|
||||
"0.5.0",
|
||||
"sqlite://s",
|
||||
"gs://a",
|
||||
"rag://m",
|
||||
None,
|
||||
(
|
||||
"--session_service_uri=sqlite://s --artifact_service_uri=gs://a"
|
||||
" --memory_service_uri=rag://m"
|
||||
),
|
||||
),
|
||||
(
|
||||
"1.3.0",
|
||||
"sqlite://s",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
"--session_service_uri=sqlite://s",
|
||||
),
|
||||
(
|
||||
"1.3.0",
|
||||
None,
|
||||
"gs://a",
|
||||
"rag://m",
|
||||
None,
|
||||
"--artifact_service_uri=gs://a --memory_service_uri=rag://m",
|
||||
),
|
||||
(
|
||||
"1.2.0",
|
||||
None,
|
||||
"gs://a",
|
||||
None,
|
||||
None,
|
||||
"--artifact_service_uri=gs://a",
|
||||
),
|
||||
(
|
||||
"1.21.0",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
False,
|
||||
"--no_use_local_storage",
|
||||
),
|
||||
(
|
||||
"1.21.0",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
True,
|
||||
"--use_local_storage",
|
||||
),
|
||||
(
|
||||
"1.21.0",
|
||||
"sqlite://s",
|
||||
"gs://a",
|
||||
None,
|
||||
False,
|
||||
"--session_service_uri=sqlite://s --artifact_service_uri=gs://a",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_get_service_option_by_adk_version(
|
||||
adk_version: str,
|
||||
session_uri: str | None,
|
||||
artifact_uri: str | None,
|
||||
memory_uri: str | None,
|
||||
use_local_storage: bool | None,
|
||||
expected: str,
|
||||
) -> None:
|
||||
"""It should return the correct service URI flags for a given ADK version."""
|
||||
actual = cli_deploy._get_service_option_by_adk_version(
|
||||
adk_version=adk_version,
|
||||
session_uri=session_uri,
|
||||
artifact_uri=artifact_uri,
|
||||
memory_uri=memory_uri,
|
||||
use_local_storage=use_local_storage,
|
||||
)
|
||||
assert actual.rstrip() == expected.rstrip()
|
||||
|
||||
|
||||
def test_print_agent_engine_url() -> None:
|
||||
"""It should print the correct URL for a fully-qualified resource name."""
|
||||
with mock.patch("click.secho") as mocked_secho:
|
||||
cli_deploy._print_agent_engine_url(
|
||||
"projects/my-project/locations/us-central1/reasoningEngines/123456"
|
||||
)
|
||||
mocked_secho.assert_called_once()
|
||||
call_args = mocked_secho.call_args[0][0]
|
||||
assert "my-project" in call_args
|
||||
assert "us-central1" in call_args
|
||||
assert "123456" in call_args
|
||||
assert "playground" in call_args
|
||||
|
||||
|
||||
@pytest.mark.parametrize("include_requirements", [True, False])
|
||||
def test_to_agent_engine_happy_path(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
agent_dir: Callable[[bool, bool], Path],
|
||||
include_requirements: bool,
|
||||
) -> None:
|
||||
"""Tests the happy path for the `to_agent_engine` function."""
|
||||
rmtree_recorder = _Recorder()
|
||||
monkeypatch.setattr(shutil, "rmtree", rmtree_recorder)
|
||||
create_recorder = _Recorder()
|
||||
|
||||
fake_vertexai = types.ModuleType("vertexai")
|
||||
|
||||
class _FakeAgentEngines:
|
||||
|
||||
def create(self, **kwargs: Any) -> Any:
|
||||
create_recorder(**kwargs)
|
||||
return types.SimpleNamespace(
|
||||
api_resource=types.SimpleNamespace(
|
||||
name="projects/p/locations/l/reasoningEngines/e"
|
||||
)
|
||||
)
|
||||
|
||||
def update(self, *, name: str, config: Dict[str, Any]) -> None:
|
||||
del name
|
||||
del config
|
||||
|
||||
class _FakeVertexClient:
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
del args
|
||||
del kwargs
|
||||
self.agent_engines = _FakeAgentEngines()
|
||||
|
||||
fake_vertexai.Client = _FakeVertexClient
|
||||
monkeypatch.setitem(sys.modules, "vertexai", fake_vertexai)
|
||||
src_dir = agent_dir(include_requirements, False)
|
||||
tmp_dir = src_dir.parent / "tmp"
|
||||
cli_deploy.to_agent_engine(
|
||||
agent_folder=str(src_dir),
|
||||
temp_folder="tmp",
|
||||
trace_to_cloud=True,
|
||||
project="my-gcp-project",
|
||||
region="us-central1",
|
||||
display_name="My Test Agent",
|
||||
description="A test agent.",
|
||||
adk_version="1.2.0",
|
||||
)
|
||||
agent_file = tmp_dir / "Dockerfile"
|
||||
assert agent_file.is_file()
|
||||
assert len(create_recorder.calls) == 1
|
||||
assert str(rmtree_recorder.get_last_call_args()[0]) == str(tmp_dir)
|
||||
|
||||
requirements_file = tmp_dir / "agents" / "agent" / "requirements.txt"
|
||||
assert requirements_file.is_file()
|
||||
assert (
|
||||
"google-cloud-aiplatform[adk,agent_engines]"
|
||||
in requirements_file.read_text()
|
||||
)
|
||||
|
||||
|
||||
def test_to_agent_engine_raises_when_explicit_config_file_missing(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
agent_dir: Callable[[bool, bool], Path],
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""It should fail with a clear error when --agent_engine_config_file is missing."""
|
||||
monkeypatch.setattr(shutil, "rmtree", lambda *a, **k: None)
|
||||
src_dir = agent_dir(False, False)
|
||||
missing_config = tmp_path / "no_such_agent_engine_config.json"
|
||||
expected_abs = str(missing_config.resolve())
|
||||
|
||||
with pytest.raises(click.ClickException) as exc_info:
|
||||
cli_deploy.to_agent_engine(
|
||||
agent_folder=str(src_dir),
|
||||
temp_folder="tmp",
|
||||
trace_to_cloud=True,
|
||||
project="my-gcp-project",
|
||||
region="us-central1",
|
||||
display_name="My Test Agent",
|
||||
description="A test agent.",
|
||||
agent_engine_config_file=str(missing_config),
|
||||
adk_version="1.2.0",
|
||||
)
|
||||
|
||||
assert "Agent Platform config file not found" in str(exc_info.value)
|
||||
assert expected_abs in str(exc_info.value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("include_requirements", [True, False])
|
||||
def test_to_gke_happy_path(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
agent_dir: Callable[[bool, bool], Path],
|
||||
tmp_path: Path,
|
||||
include_requirements: bool,
|
||||
) -> None:
|
||||
"""
|
||||
Tests the happy path for the `to_gke` function.
|
||||
"""
|
||||
src_dir = agent_dir(include_requirements, False)
|
||||
run_recorder = _Recorder()
|
||||
rmtree_recorder = _Recorder()
|
||||
|
||||
def mock_subprocess_run(*args, **kwargs):
|
||||
run_recorder(*args, **kwargs)
|
||||
command_list = args[0]
|
||||
if command_list and command_list[0:2] == ["kubectl", "apply"]:
|
||||
fake_stdout = "deployment.apps/gke-svc created\nservice/gke-svc created"
|
||||
return types.SimpleNamespace(stdout=fake_stdout)
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", mock_subprocess_run)
|
||||
monkeypatch.setattr(shutil, "rmtree", rmtree_recorder)
|
||||
|
||||
cli_deploy.to_gke(
|
||||
agent_folder=str(src_dir),
|
||||
project="gke-proj",
|
||||
region="us-east1",
|
||||
cluster_name="my-gke-cluster",
|
||||
service_name="gke-svc",
|
||||
app_name="agent",
|
||||
temp_folder=str(tmp_path),
|
||||
port=9090,
|
||||
trace_to_cloud=False,
|
||||
otel_to_cloud=False,
|
||||
with_ui=True,
|
||||
log_level="debug",
|
||||
adk_version="1.2.0",
|
||||
allow_origins=["http://localhost:3000", "https://my-app.com"],
|
||||
session_service_uri="sqlite:///",
|
||||
artifact_service_uri="gs://gke-bucket",
|
||||
)
|
||||
|
||||
dockerfile_path = tmp_path / "Dockerfile"
|
||||
assert dockerfile_path.is_file()
|
||||
dockerfile_content = dockerfile_path.read_text()
|
||||
assert "CMD adk api_server --with_ui --port=9090" in dockerfile_content
|
||||
assert 'RUN pip install "google-adk[a2a]==1.2.0"' in dockerfile_content
|
||||
|
||||
assert len(run_recorder.calls) == 3, "Expected 3 subprocess calls"
|
||||
|
||||
build_args = run_recorder.calls[0][0][0]
|
||||
expected_build_args = [
|
||||
"gcloud",
|
||||
"builds",
|
||||
"submit",
|
||||
"--tag",
|
||||
"gcr.io/gke-proj/gke-svc",
|
||||
"--verbosity",
|
||||
"debug",
|
||||
str(tmp_path),
|
||||
]
|
||||
assert build_args == expected_build_args
|
||||
|
||||
creds_args = run_recorder.calls[1][0][0]
|
||||
expected_creds_args = [
|
||||
"gcloud",
|
||||
"container",
|
||||
"clusters",
|
||||
"get-credentials",
|
||||
"my-gke-cluster",
|
||||
"--region",
|
||||
"us-east1",
|
||||
"--project",
|
||||
"gke-proj",
|
||||
]
|
||||
assert creds_args == expected_creds_args
|
||||
|
||||
assert (
|
||||
"--allow_origins=http://localhost:3000,https://my-app.com"
|
||||
in dockerfile_content
|
||||
)
|
||||
|
||||
apply_args = run_recorder.calls[2][0][0]
|
||||
expected_apply_args = ["kubectl", "apply", "-f", str(tmp_path)]
|
||||
assert apply_args == expected_apply_args
|
||||
|
||||
deployment_yaml_path = tmp_path / "deployment.yaml"
|
||||
assert deployment_yaml_path.is_file()
|
||||
yaml_content = deployment_yaml_path.read_text()
|
||||
|
||||
assert "kind: Deployment" in yaml_content
|
||||
assert "kind: Service" in yaml_content
|
||||
assert "name: gke-svc" in yaml_content
|
||||
assert "image: gcr.io/gke-proj/gke-svc" in yaml_content
|
||||
assert f"containerPort: 9090" in yaml_content
|
||||
assert f"targetPort: 9090" in yaml_content
|
||||
assert "type: ClusterIP" in yaml_content
|
||||
|
||||
# 4. Verify cleanup
|
||||
assert str(rmtree_recorder.get_last_call_args()[0]) == str(tmp_path)
|
||||
|
||||
|
||||
# _validate_agent_import tests
|
||||
class TestValidateAgentImport:
|
||||
"""Tests for the _validate_agent_import function."""
|
||||
|
||||
def test_skips_config_agents(self, tmp_path: Path) -> None:
|
||||
"""Config agents should skip validation."""
|
||||
# This should not raise even with no agent.py file
|
||||
cli_deploy._validate_agent_import(
|
||||
str(tmp_path), "root_agent", is_config_agent=True
|
||||
)
|
||||
|
||||
def test_raises_on_missing_agent_module(self, tmp_path: Path) -> None:
|
||||
"""Should raise when agent.py is missing."""
|
||||
with pytest.raises(click.ClickException) as exc_info:
|
||||
cli_deploy._validate_agent_import(
|
||||
str(tmp_path), "root_agent", is_config_agent=False
|
||||
)
|
||||
assert "Agent module not found" in str(exc_info.value)
|
||||
|
||||
def test_raises_on_missing_export(self, tmp_path: Path) -> None:
|
||||
"""Should raise when the expected export is missing."""
|
||||
agent_file = tmp_path / "agent.py"
|
||||
agent_file.write_text("some_other_var = 'hello'\n")
|
||||
(tmp_path / "__init__.py").touch()
|
||||
|
||||
with pytest.raises(click.ClickException) as exc_info:
|
||||
cli_deploy._validate_agent_import(
|
||||
str(tmp_path), "root_agent", is_config_agent=False
|
||||
)
|
||||
assert "does not export 'root_agent'" in str(exc_info.value)
|
||||
assert "some_other_var" in str(exc_info.value)
|
||||
|
||||
def test_success_with_root_agent_export(self, tmp_path: Path) -> None:
|
||||
"""Should succeed when root_agent is exported."""
|
||||
agent_file = tmp_path / "agent.py"
|
||||
agent_file.write_text("root_agent = 'my_agent'\n")
|
||||
(tmp_path / "__init__.py").touch()
|
||||
|
||||
# Should not raise
|
||||
cli_deploy._validate_agent_import(
|
||||
str(tmp_path), "root_agent", is_config_agent=False
|
||||
)
|
||||
|
||||
def test_success_with_app_export(self, tmp_path: Path) -> None:
|
||||
"""Should succeed when app is exported."""
|
||||
agent_file = tmp_path / "agent.py"
|
||||
agent_file.write_text("app = 'my_app'\n")
|
||||
(tmp_path / "__init__.py").touch()
|
||||
|
||||
# Should not raise
|
||||
cli_deploy._validate_agent_import(
|
||||
str(tmp_path), "app", is_config_agent=False
|
||||
)
|
||||
|
||||
def test_success_with_relative_imports(self, tmp_path: Path) -> None:
|
||||
"""Should succeed when agent.py uses relative imports."""
|
||||
(tmp_path / "helper.py").write_text("VALUE = 'my_agent'\n")
|
||||
(tmp_path / "__init__.py").touch()
|
||||
(tmp_path / "agent.py").write_text(
|
||||
"from .helper import VALUE\n\nroot_agent = VALUE\n"
|
||||
)
|
||||
|
||||
cli_deploy._validate_agent_import(
|
||||
str(tmp_path), "root_agent", is_config_agent=False
|
||||
)
|
||||
|
||||
def test_raises_on_import_error(self, tmp_path: Path) -> None:
|
||||
"""Should raise with helpful message on ImportError."""
|
||||
agent_file = tmp_path / "agent.py"
|
||||
agent_file.write_text("from nonexistent_module import something\n")
|
||||
(tmp_path / "__init__.py").touch()
|
||||
|
||||
with pytest.raises(click.ClickException) as exc_info:
|
||||
cli_deploy._validate_agent_import(
|
||||
str(tmp_path), "root_agent", is_config_agent=False
|
||||
)
|
||||
assert "Failed to import agent module" in str(exc_info.value)
|
||||
assert "nonexistent_module" in str(exc_info.value)
|
||||
|
||||
def test_raises_on_basellm_import_error(self, tmp_path: Path) -> None:
|
||||
"""Should provide specific guidance for BaseLlm import errors."""
|
||||
agent_file = tmp_path / "agent.py"
|
||||
agent_file.write_text(
|
||||
"from google.adk.models.base_llm import NonexistentBaseLlm\n"
|
||||
)
|
||||
(tmp_path / "__init__.py").touch()
|
||||
|
||||
with pytest.raises(click.ClickException) as exc_info:
|
||||
cli_deploy._validate_agent_import(
|
||||
str(tmp_path), "root_agent", is_config_agent=False
|
||||
)
|
||||
assert "BaseLlm-related error" in str(exc_info.value)
|
||||
assert "custom LLM" in str(exc_info.value)
|
||||
|
||||
def test_raises_on_syntax_error(self, tmp_path: Path) -> None:
|
||||
"""Should raise on syntax errors in agent.py."""
|
||||
agent_file = tmp_path / "agent.py"
|
||||
agent_file.write_text("def invalid syntax here:\n")
|
||||
(tmp_path / "__init__.py").touch()
|
||||
|
||||
with pytest.raises(click.ClickException) as exc_info:
|
||||
cli_deploy._validate_agent_import(
|
||||
str(tmp_path), "root_agent", is_config_agent=False
|
||||
)
|
||||
assert "Error while loading agent module" in str(exc_info.value)
|
||||
|
||||
def test_cleans_up_sys_modules(self, tmp_path: Path) -> None:
|
||||
"""Should clean up sys.modules after validation."""
|
||||
agent_file = tmp_path / "agent.py"
|
||||
agent_file.write_text("root_agent = 'my_agent'\n")
|
||||
(tmp_path / "__init__.py").touch()
|
||||
|
||||
module_name = tmp_path.name
|
||||
agent_module_key = f"{module_name}.agent"
|
||||
|
||||
# Ensure module is not in sys.modules before
|
||||
assert module_name not in sys.modules
|
||||
assert agent_module_key not in sys.modules
|
||||
|
||||
cli_deploy._validate_agent_import(
|
||||
str(tmp_path), "root_agent", is_config_agent=False
|
||||
)
|
||||
|
||||
# Ensure module is cleaned up after
|
||||
assert module_name not in sys.modules
|
||||
assert agent_module_key not in sys.modules
|
||||
|
||||
def test_restores_sys_path(self, tmp_path: Path) -> None:
|
||||
"""Should restore sys.path after validation."""
|
||||
agent_file = tmp_path / "agent.py"
|
||||
agent_file.write_text("root_agent = 'my_agent'\n")
|
||||
(tmp_path / "__init__.py").touch()
|
||||
|
||||
original_path = sys.path.copy()
|
||||
|
||||
cli_deploy._validate_agent_import(
|
||||
str(tmp_path), "root_agent", is_config_agent=False
|
||||
)
|
||||
|
||||
assert sys.path == original_path
|
||||
|
||||
|
||||
def test_to_agent_engine_triggers_onboarding(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
agent_dir: Callable[[bool, bool], Path],
|
||||
) -> None:
|
||||
"""It should trigger onboarding when credentials are missing."""
|
||||
mock_handle_login = mock.Mock(
|
||||
return_value=cli_deploy._onboarding.ExpressModeAuth(
|
||||
api_key="fake_api_key",
|
||||
project_id="fake_project",
|
||||
region="fake_region",
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
cli_deploy._onboarding, "handle_login_with_google", mock_handle_login
|
||||
)
|
||||
|
||||
# Mock subprocess.run so `gcloud config get-value project` returns no
|
||||
# default project; otherwise `_resolve_project` would populate `project`
|
||||
# and suppress the onboarding flow this test is exercising.
|
||||
monkeypatch.setattr(
|
||||
subprocess,
|
||||
"run",
|
||||
lambda *a, **k: types.SimpleNamespace(stdout="\n"),
|
||||
)
|
||||
|
||||
fake_vertexai = types.ModuleType("vertexai")
|
||||
mock_client = mock.Mock()
|
||||
fake_vertexai.Client = mock.Mock(return_value=mock_client)
|
||||
|
||||
mock_agent_engines = mock.Mock()
|
||||
mock_client.agent_engines = mock_agent_engines
|
||||
|
||||
mock_agent_engines.create.return_value = types.SimpleNamespace(
|
||||
api_resource=types.SimpleNamespace(
|
||||
name="projects/p/locations/l/reasoningEngines/e"
|
||||
)
|
||||
)
|
||||
mock_agent_engines.delete.return_value = None
|
||||
mock_agent_engines.update.return_value = None
|
||||
|
||||
monkeypatch.setitem(sys.modules, "vertexai", fake_vertexai)
|
||||
|
||||
src_dir = agent_dir(False, False)
|
||||
|
||||
cli_deploy.to_agent_engine(
|
||||
agent_folder=str(src_dir),
|
||||
trace_to_cloud=True,
|
||||
)
|
||||
|
||||
mock_handle_login.assert_called_once()
|
||||
|
||||
# Verify vertexai.Client was initialized with correct args
|
||||
fake_vertexai.Client.assert_called_once()
|
||||
kwargs = fake_vertexai.Client.call_args.kwargs
|
||||
assert kwargs.get("project") == "fake_project"
|
||||
assert kwargs.get("location") == "fake_region"
|
||||
assert "api_key" not in kwargs or kwargs.get("api_key") is None
|
||||
|
||||
|
||||
def test_cli_deploy_agent_engine_trigger_sources(tmp_path: Path):
|
||||
"""Tests that --trigger_sources is passed to to_agent_engine."""
|
||||
agent_dir = tmp_path / "my_agent"
|
||||
agent_dir.mkdir()
|
||||
runner = CliRunner()
|
||||
with mock.patch(
|
||||
"src.google.adk.cli.cli_deploy.to_agent_engine"
|
||||
) as mock_to_agent_engine:
|
||||
result = runner.invoke(
|
||||
cli_tools_click.main,
|
||||
[
|
||||
"deploy",
|
||||
"agent_engine",
|
||||
"--trigger_sources=pubsub,eventarc",
|
||||
str(agent_dir),
|
||||
],
|
||||
catch_exceptions=False,
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
mock_to_agent_engine.assert_called_once()
|
||||
_, kwargs = mock_to_agent_engine.call_args
|
||||
assert kwargs["trigger_sources"] == "pubsub,eventarc"
|
||||
|
||||
|
||||
def test_cli_deploy_agent_engine_artifact_service_uri(tmp_path: Path):
|
||||
"""Tests that --artifact_service_uri is passed to to_agent_engine."""
|
||||
agent_dir = tmp_path / "my_agent"
|
||||
agent_dir.mkdir()
|
||||
runner = CliRunner()
|
||||
with mock.patch(
|
||||
"src.google.adk.cli.cli_deploy.to_agent_engine"
|
||||
) as mock_to_agent_engine:
|
||||
result = runner.invoke(
|
||||
cli_tools_click.main,
|
||||
[
|
||||
"deploy",
|
||||
"agent_engine",
|
||||
"--artifact_service_uri=gs://my-bucket",
|
||||
str(agent_dir),
|
||||
],
|
||||
catch_exceptions=False,
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
mock_to_agent_engine.assert_called_once()
|
||||
_, kwargs = mock_to_agent_engine.call_args
|
||||
assert kwargs["artifact_service_uri"] == "gs://my-bucket"
|
||||
|
||||
|
||||
def test_ensure_agent_engine_dependency(tmp_path: Path):
|
||||
"""Tests that _ensure_agent_engine_dependency appends correct extras."""
|
||||
requirements_file = tmp_path / "requirements.txt"
|
||||
|
||||
# Case 1: raises FileNotFoundError when the file doesn't exist
|
||||
with pytest.raises(FileNotFoundError):
|
||||
cli_deploy._ensure_agent_engine_dependency(str(requirements_file))
|
||||
|
||||
# Case 2: appends google-cloud-aiplatform with 'adk' and 'agent_engines'
|
||||
# extras and the versioned google-adk requirement.
|
||||
requirements_file.write_text("")
|
||||
cli_deploy._ensure_agent_engine_dependency(str(requirements_file))
|
||||
content = requirements_file.read_text()
|
||||
assert "google-cloud-aiplatform[adk,agent_engines]\n" in content
|
||||
assert f"google-adk[a2a]=={cli_deploy.__version__}\n" in content
|
||||
|
||||
# Case 3: does not append duplicate if google-cloud-aiplatform already exists
|
||||
requirements_file.write_text("google-cloud-aiplatform[adk,agent_engines]\n")
|
||||
cli_deploy._ensure_agent_engine_dependency(str(requirements_file))
|
||||
content = requirements_file.read_text()
|
||||
assert content == "google-cloud-aiplatform[adk,agent_engines]\n"
|
||||
@@ -0,0 +1,219 @@
|
||||
# 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 ignore file support in cli_deploy."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
from unittest import mock
|
||||
|
||||
import click
|
||||
import pytest
|
||||
|
||||
import src.google.adk.cli.cli_deploy as cli_deploy
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _mute_click(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Suppress click.echo to keep test output clean."""
|
||||
monkeypatch.setattr(click, "echo", lambda *_a, **_k: None)
|
||||
monkeypatch.setattr(click, "secho", lambda *_a, **_k: None)
|
||||
|
||||
|
||||
def test_to_cloud_run_respects_ignore_files(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test that to_cloud_run respects .gitignore and .gcloudignore."""
|
||||
agent_dir = tmp_path / "agent"
|
||||
agent_dir.mkdir()
|
||||
(agent_dir / "agent.py").write_text("# agent")
|
||||
(agent_dir / "__init__.py").write_text("")
|
||||
(agent_dir / "ignored_by_git.txt").write_text("ignored")
|
||||
(agent_dir / "ignored_by_gcloud.txt").write_text("ignored")
|
||||
(agent_dir / "ignored_rooted.txt").write_text("ignored")
|
||||
(agent_dir / "not_ignored.txt").write_text("keep")
|
||||
|
||||
# Use a root-anchored pattern (leading slash) to ensure it is honored.
|
||||
(agent_dir / ".gitignore").write_text(
|
||||
"ignored_by_git.txt\n/ignored_rooted.txt\n"
|
||||
)
|
||||
(agent_dir / ".gcloudignore").write_text("ignored_by_gcloud.txt\n")
|
||||
|
||||
temp_deploy_dir = tmp_path / "temp_deploy"
|
||||
|
||||
# Mock subprocess.run to avoid actual gcloud call
|
||||
monkeypatch.setattr(subprocess, "run", mock.Mock())
|
||||
# Mock shutil.rmtree to keep the temp folder for verification
|
||||
monkeypatch.setattr(
|
||||
shutil,
|
||||
"rmtree",
|
||||
lambda path, **kwargs: None
|
||||
if "temp_deploy" in str(path)
|
||||
else shutil.rmtree(path, **kwargs),
|
||||
)
|
||||
|
||||
cli_deploy.to_cloud_run(
|
||||
agent_folder=str(agent_dir),
|
||||
project="proj",
|
||||
region="us-central1",
|
||||
service_name="svc",
|
||||
app_name="app",
|
||||
temp_folder=str(temp_deploy_dir),
|
||||
port=8080,
|
||||
trace_to_cloud=False,
|
||||
otel_to_cloud=False,
|
||||
with_ui=False,
|
||||
log_level="info",
|
||||
verbosity="info",
|
||||
adk_version="1.0.0",
|
||||
)
|
||||
|
||||
agent_src_path = temp_deploy_dir / "agents" / "app"
|
||||
|
||||
assert (agent_src_path / "agent.py").exists()
|
||||
assert (agent_src_path / "not_ignored.txt").exists()
|
||||
|
||||
# These should be ignored
|
||||
assert not (
|
||||
agent_src_path / "ignored_by_git.txt"
|
||||
).exists(), "Should respect .gitignore"
|
||||
assert not (
|
||||
agent_src_path / "ignored_by_gcloud.txt"
|
||||
).exists(), "Should respect .gcloudignore"
|
||||
assert not (
|
||||
agent_src_path / "ignored_rooted.txt"
|
||||
).exists(), "Should respect root-anchored (leading slash) patterns"
|
||||
|
||||
|
||||
def test_to_agent_engine_respects_multiple_ignore_files(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test that to_agent_engine respects .gitignore, .gcloudignore and .ae_ignore."""
|
||||
# We need to be in the project dir for to_agent_engine
|
||||
project_dir = tmp_path / "project"
|
||||
project_dir.mkdir()
|
||||
monkeypatch.chdir(project_dir)
|
||||
|
||||
agent_dir = project_dir / "my_agent"
|
||||
agent_dir.mkdir()
|
||||
(agent_dir / "agent.py").write_text("root_agent = None")
|
||||
(agent_dir / "__init__.py").write_text("from . import agent")
|
||||
(agent_dir / "ignored_by_git.txt").write_text("ignored")
|
||||
(agent_dir / "ignored_by_ae.txt").write_text("ignored")
|
||||
|
||||
(agent_dir / ".gitignore").write_text("ignored_by_git.txt\n")
|
||||
(agent_dir / ".ae_ignore").write_text("ignored_by_ae.txt\n")
|
||||
|
||||
# Mock vertexai.Client and other things to avoid network/complex setup. The
|
||||
# created agent engine must expose a realistic resource name so the downstream
|
||||
# console-URL formatting does not choke on a bare Mock.
|
||||
mock_client = mock.Mock()
|
||||
mock_client.agent_engines.create.return_value.api_resource.name = (
|
||||
"projects/proj/locations/us-central1/reasoningEngines/123"
|
||||
)
|
||||
monkeypatch.setattr("vertexai.Client", mock.Mock(return_value=mock_client))
|
||||
# Mock shutil.rmtree to keep the temp folder for verification
|
||||
original_rmtree = shutil.rmtree
|
||||
|
||||
def mock_rmtree(path, **kwargs):
|
||||
if "_tmp" in str(path):
|
||||
return None
|
||||
return original_rmtree(path, **kwargs)
|
||||
|
||||
monkeypatch.setattr(shutil, "rmtree", mock_rmtree)
|
||||
|
||||
cli_deploy.to_agent_engine(
|
||||
agent_folder=str(agent_dir),
|
||||
staging_bucket="gs://test",
|
||||
adk_app="adk_app",
|
||||
# Pass project/region explicitly so the function does not fall back to
|
||||
# the interactive `gcloud auth application-default login` onboarding flow,
|
||||
# which fails on CI runners without Application Default Credentials.
|
||||
project="proj",
|
||||
region="us-central1",
|
||||
adk_version="1.0.0",
|
||||
)
|
||||
|
||||
# Find the temp folder created by to_agent_engine
|
||||
temp_folders = [
|
||||
d for d in project_dir.iterdir() if d.is_dir() and "_tmp" in d.name
|
||||
]
|
||||
assert len(temp_folders) == 1
|
||||
agent_src_path = temp_folders[0]
|
||||
|
||||
copied_agent_dir = agent_src_path / "agents" / "my_agent"
|
||||
assert (copied_agent_dir / "agent.py").exists()
|
||||
assert not (
|
||||
copied_agent_dir / "ignored_by_git.txt"
|
||||
).exists(), "Should respect .gitignore"
|
||||
assert not (
|
||||
copied_agent_dir / "ignored_by_ae.txt"
|
||||
).exists(), "Should respect .ae_ignore"
|
||||
|
||||
|
||||
def test_to_gke_respects_ignore_files(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test that to_gke respects ignore files."""
|
||||
agent_dir = tmp_path / "agent"
|
||||
agent_dir.mkdir()
|
||||
(agent_dir / "agent.py").write_text("# agent")
|
||||
(agent_dir / "__init__.py").write_text("")
|
||||
(agent_dir / "ignored.txt").write_text("ignored")
|
||||
(agent_dir / ".gitignore").write_text("ignored.txt\n")
|
||||
|
||||
temp_deploy_dir = tmp_path / "temp_deploy"
|
||||
|
||||
# Mock subprocess.run to avoid actual gcloud call
|
||||
mock_run = mock.Mock()
|
||||
mock_run.return_value.stdout = "deployment created"
|
||||
monkeypatch.setattr(subprocess, "run", mock_run)
|
||||
# Mock shutil.rmtree to keep the temp folder for verification
|
||||
monkeypatch.setattr(
|
||||
shutil,
|
||||
"rmtree",
|
||||
lambda path, **kwargs: None
|
||||
if "temp_deploy" in str(path)
|
||||
else shutil.rmtree(path, **kwargs),
|
||||
)
|
||||
|
||||
cli_deploy.to_gke(
|
||||
agent_folder=str(agent_dir),
|
||||
project="proj",
|
||||
region="us-central1",
|
||||
cluster_name="cluster",
|
||||
service_name="svc",
|
||||
app_name="app",
|
||||
temp_folder=str(temp_deploy_dir),
|
||||
port=8080,
|
||||
trace_to_cloud=False,
|
||||
otel_to_cloud=False,
|
||||
with_ui=False,
|
||||
log_level="info",
|
||||
adk_version="1.0.0",
|
||||
)
|
||||
|
||||
agent_src_path = temp_deploy_dir / "agents" / "app"
|
||||
|
||||
assert (agent_src_path / "agent.py").exists()
|
||||
assert not (
|
||||
agent_src_path / "ignored.txt"
|
||||
).exists(), "Should respect .gitignore"
|
||||
@@ -0,0 +1,349 @@
|
||||
# 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 to_cloud_run functionality in cli_deploy."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from typing import Any
|
||||
from typing import Dict
|
||||
from typing import List
|
||||
from typing import Protocol
|
||||
from typing import Tuple
|
||||
from unittest import mock
|
||||
|
||||
import click
|
||||
import pytest
|
||||
|
||||
import src.google.adk.cli.cli_deploy as cli_deploy
|
||||
|
||||
|
||||
class AgentDirFixture(Protocol):
|
||||
"""Protocol for the agent_dir pytest fixture factory."""
|
||||
|
||||
def __call__(self, *, include_requirements: bool, include_env: bool) -> Path:
|
||||
...
|
||||
|
||||
|
||||
# Helpers
|
||||
class _Recorder:
|
||||
"""A callable object that records every invocation."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls: List[Tuple[Tuple[Any, ...], Dict[str, Any]]] = []
|
||||
|
||||
def __call__(self, *args: Any, **kwargs: Any) -> None:
|
||||
self.calls.append((args, kwargs))
|
||||
|
||||
def get_last_call_args(self) -> Tuple[Any, ...]:
|
||||
"""Returns the positional arguments of the last call."""
|
||||
if not self.calls:
|
||||
raise IndexError("No calls have been recorded.")
|
||||
return self.calls[-1][0]
|
||||
|
||||
def get_last_call_kwargs(self) -> Dict[str, Any]:
|
||||
"""Returns the keyword arguments of the last call."""
|
||||
if not self.calls:
|
||||
raise IndexError("No calls have been recorded.")
|
||||
return self.calls[-1][1]
|
||||
|
||||
|
||||
# Fixtures
|
||||
@pytest.fixture(autouse=True)
|
||||
def _mute_click(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Suppress click.echo to keep test output clean."""
|
||||
monkeypatch.setattr(click, "echo", lambda *_a, **_k: None)
|
||||
monkeypatch.setattr(click, "secho", lambda *_a, **_k: None)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def agent_dir(tmp_path: Path) -> AgentDirFixture:
|
||||
"""
|
||||
Return a factory that creates a dummy agent directory tree.
|
||||
"""
|
||||
|
||||
def _factory(*, include_requirements: bool, include_env: bool) -> Path:
|
||||
base = tmp_path / "agent"
|
||||
base.mkdir()
|
||||
(base / "agent.py").write_text("# dummy agent")
|
||||
(base / "__init__.py").write_text("from . import agent")
|
||||
if include_requirements:
|
||||
(base / "requirements.txt").write_text("pytest\n")
|
||||
if include_env:
|
||||
(base / ".env").write_text('TEST_VAR="test_value"\n')
|
||||
return base
|
||||
|
||||
return _factory
|
||||
|
||||
|
||||
@pytest.mark.parametrize("include_requirements", [True, False])
|
||||
@pytest.mark.parametrize("with_ui", [True, False])
|
||||
def test_to_cloud_run_happy_path(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
agent_dir: AgentDirFixture,
|
||||
tmp_path: Path,
|
||||
include_requirements: bool,
|
||||
with_ui: bool,
|
||||
) -> None:
|
||||
"""
|
||||
End-to-end execution test for `to_cloud_run`.
|
||||
"""
|
||||
src_dir = agent_dir(
|
||||
include_requirements=include_requirements, include_env=False
|
||||
)
|
||||
run_recorder = _Recorder()
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", run_recorder)
|
||||
rmtree_recorder = _Recorder()
|
||||
monkeypatch.setattr(shutil, "rmtree", rmtree_recorder)
|
||||
|
||||
cli_deploy.to_cloud_run(
|
||||
agent_folder=str(src_dir),
|
||||
project="proj",
|
||||
region="asia-northeast1",
|
||||
service_name="svc",
|
||||
app_name="agent",
|
||||
temp_folder=str(tmp_path),
|
||||
port=8080,
|
||||
trace_to_cloud=True,
|
||||
otel_to_cloud=True,
|
||||
with_ui=with_ui,
|
||||
log_level="info",
|
||||
verbosity="info",
|
||||
allow_origins=["http://localhost:3000", "https://my-app.com"],
|
||||
session_service_uri="sqlite://",
|
||||
artifact_service_uri="gs://bucket",
|
||||
memory_service_uri="rag://",
|
||||
adk_version="1.3.0",
|
||||
)
|
||||
|
||||
agent_dest_path = tmp_path / "agents" / "agent"
|
||||
assert (agent_dest_path / "agent.py").is_file()
|
||||
assert (agent_dest_path / "__init__.py").is_file()
|
||||
assert (
|
||||
agent_dest_path / "requirements.txt"
|
||||
).is_file() == include_requirements
|
||||
|
||||
dockerfile_path = tmp_path / "Dockerfile"
|
||||
assert dockerfile_path.is_file()
|
||||
dockerfile_content = dockerfile_path.read_text()
|
||||
|
||||
expected_command = "api_server --with_ui" if with_ui else "api_server"
|
||||
assert f"CMD adk {expected_command} --port=8080" in dockerfile_content
|
||||
assert "FROM python:3.11-slim" in dockerfile_content
|
||||
assert (
|
||||
'RUN adduser --disabled-password --gecos "" myuser' in dockerfile_content
|
||||
)
|
||||
assert "USER myuser" in dockerfile_content
|
||||
assert "ENV GOOGLE_CLOUD_PROJECT=proj" in dockerfile_content
|
||||
assert "ENV GOOGLE_CLOUD_LOCATION=asia-northeast1" in dockerfile_content
|
||||
assert 'RUN pip install "google-adk[a2a]==1.3.0"' in dockerfile_content
|
||||
assert "--trace_to_cloud" in dockerfile_content
|
||||
assert "--otel_to_cloud" in dockerfile_content
|
||||
|
||||
# Check agent dependencies installation based on include_requirements
|
||||
if include_requirements:
|
||||
assert (
|
||||
'RUN pip install -r "/app/agents/agent/requirements.txt"'
|
||||
in dockerfile_content
|
||||
)
|
||||
else:
|
||||
assert "# No requirements.txt found." in dockerfile_content
|
||||
|
||||
assert (
|
||||
"--allow_origins=http://localhost:3000,https://my-app.com"
|
||||
in dockerfile_content
|
||||
)
|
||||
|
||||
assert len(run_recorder.calls) == 1
|
||||
gcloud_args = run_recorder.get_last_call_args()[0]
|
||||
|
||||
expected_gcloud_command = [
|
||||
cli_deploy._GCLOUD_CMD,
|
||||
"run",
|
||||
"deploy",
|
||||
"svc",
|
||||
"--source",
|
||||
str(tmp_path),
|
||||
"--project",
|
||||
"proj",
|
||||
"--region",
|
||||
"asia-northeast1",
|
||||
"--port",
|
||||
"8080",
|
||||
"--verbosity",
|
||||
"info",
|
||||
"--sandbox-launcher",
|
||||
"--labels",
|
||||
"created-by=adk",
|
||||
]
|
||||
assert gcloud_args == expected_gcloud_command
|
||||
|
||||
assert str(rmtree_recorder.get_last_call_args()[0]) == str(tmp_path)
|
||||
|
||||
|
||||
def test_to_cloud_run_cleans_temp_dir(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
agent_dir: AgentDirFixture,
|
||||
) -> None:
|
||||
"""`to_cloud_run` should always delete the temporary folder on exit."""
|
||||
tmp_dir = Path(tempfile.mkdtemp())
|
||||
src_dir = agent_dir(include_requirements=False, include_env=False)
|
||||
|
||||
deleted: Dict[str, Path] = {}
|
||||
|
||||
def _fake_rmtree(path: str | Path, *_a: Any, **_k: Any) -> None:
|
||||
deleted["path"] = Path(path)
|
||||
|
||||
monkeypatch.setattr(shutil, "rmtree", _fake_rmtree)
|
||||
monkeypatch.setattr(subprocess, "run", _Recorder())
|
||||
|
||||
cli_deploy.to_cloud_run(
|
||||
agent_folder=str(src_dir),
|
||||
project="proj",
|
||||
region=None,
|
||||
service_name="svc",
|
||||
app_name="app",
|
||||
temp_folder=str(tmp_dir),
|
||||
port=8080,
|
||||
trace_to_cloud=False,
|
||||
otel_to_cloud=False,
|
||||
with_ui=False,
|
||||
log_level="info",
|
||||
verbosity="info",
|
||||
adk_version="1.0.0",
|
||||
session_service_uri=None,
|
||||
artifact_service_uri=None,
|
||||
memory_service_uri=None,
|
||||
)
|
||||
|
||||
assert deleted["path"] == tmp_dir
|
||||
|
||||
|
||||
def test_to_cloud_run_cleans_temp_dir_on_failure(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
agent_dir: AgentDirFixture,
|
||||
) -> None:
|
||||
"""`to_cloud_run` should delete the temp folder on exit, even if gcloud fails."""
|
||||
tmp_dir = Path(tempfile.mkdtemp())
|
||||
src_dir = agent_dir(include_requirements=False, include_env=False)
|
||||
|
||||
rmtree_recorder = _Recorder()
|
||||
monkeypatch.setattr(shutil, "rmtree", rmtree_recorder)
|
||||
monkeypatch.setattr(
|
||||
subprocess,
|
||||
"run",
|
||||
mock.Mock(side_effect=subprocess.CalledProcessError(1, "gcloud")),
|
||||
)
|
||||
|
||||
with pytest.raises(subprocess.CalledProcessError):
|
||||
cli_deploy.to_cloud_run(
|
||||
agent_folder=str(src_dir),
|
||||
project="proj",
|
||||
region="us-central1",
|
||||
service_name="svc",
|
||||
app_name="app",
|
||||
temp_folder=str(tmp_dir),
|
||||
port=8080,
|
||||
trace_to_cloud=False,
|
||||
otel_to_cloud=False,
|
||||
with_ui=False,
|
||||
log_level="info",
|
||||
verbosity="info",
|
||||
adk_version="1.0.0",
|
||||
session_service_uri=None,
|
||||
artifact_service_uri=None,
|
||||
memory_service_uri=None,
|
||||
)
|
||||
|
||||
assert rmtree_recorder.calls, "shutil.rmtree should have been called"
|
||||
assert str(rmtree_recorder.get_last_call_args()[0]) == str(tmp_dir)
|
||||
|
||||
|
||||
# Label merging tests
|
||||
@pytest.mark.parametrize(
|
||||
"extra_gcloud_args, expected_labels",
|
||||
[
|
||||
# No user labels - should only have default ADK label
|
||||
(None, "created-by=adk"),
|
||||
([], "created-by=adk"),
|
||||
# Single user label
|
||||
(["--labels=env=test"], "created-by=adk,env=test"),
|
||||
# Multiple user labels in same argument
|
||||
(
|
||||
["--labels=env=test,team=myteam"],
|
||||
"created-by=adk,env=test,team=myteam",
|
||||
),
|
||||
# User labels mixed with other args
|
||||
(
|
||||
["--memory=1Gi", "--labels=env=test", "--cpu=1"],
|
||||
"created-by=adk,env=test",
|
||||
),
|
||||
# Multiple --labels arguments
|
||||
(
|
||||
["--labels=env=test", "--labels=team=myteam"],
|
||||
"created-by=adk,env=test,team=myteam",
|
||||
),
|
||||
# Labels with other passthrough args
|
||||
(
|
||||
["--timeout=300", "--labels=env=prod", "--max-instances=10"],
|
||||
"created-by=adk,env=prod",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_cloud_run_label_merging(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
agent_dir: AgentDirFixture,
|
||||
tmp_path: Path,
|
||||
extra_gcloud_args: list[str] | None,
|
||||
expected_labels: str,
|
||||
) -> None:
|
||||
"""Test that user labels are properly merged with the default ADK label."""
|
||||
src_dir = agent_dir(include_requirements=False, include_env=False)
|
||||
run_recorder = _Recorder()
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", run_recorder)
|
||||
monkeypatch.setattr(shutil, "rmtree", lambda _x: None)
|
||||
|
||||
# Execute the function under test
|
||||
cli_deploy.to_cloud_run(
|
||||
agent_folder=str(src_dir),
|
||||
project="test-project",
|
||||
region="us-central1",
|
||||
service_name="test-service",
|
||||
app_name="test-app",
|
||||
temp_folder=str(tmp_path),
|
||||
port=8080,
|
||||
trace_to_cloud=False,
|
||||
otel_to_cloud=False,
|
||||
with_ui=False,
|
||||
log_level="info",
|
||||
verbosity="info",
|
||||
adk_version="1.0.0",
|
||||
extra_gcloud_args=tuple(extra_gcloud_args) if extra_gcloud_args else None,
|
||||
)
|
||||
|
||||
# Verify that the gcloud command was called
|
||||
assert len(run_recorder.calls) == 1
|
||||
gcloud_args = run_recorder.get_last_call_args()[0]
|
||||
|
||||
# Find the labels argument
|
||||
labels_idx = gcloud_args.index("--labels")
|
||||
actual_labels = gcloud_args[labels_idx + 1]
|
||||
|
||||
assert actual_labels == expected_labels
|
||||
@@ -0,0 +1,51 @@
|
||||
# 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.
|
||||
|
||||
"""Unit tests for utilities in cli_eval."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest import mock
|
||||
|
||||
|
||||
def test_get_eval_sets_manager_local(monkeypatch):
|
||||
mock_local_manager = mock.MagicMock()
|
||||
monkeypatch.setattr(
|
||||
"google.adk.evaluation.local_eval_sets_manager.LocalEvalSetsManager",
|
||||
lambda *a, **k: mock_local_manager,
|
||||
)
|
||||
from google.adk.cli.cli_eval import get_eval_sets_manager
|
||||
|
||||
manager = get_eval_sets_manager(eval_storage_uri=None, agents_dir="some/dir")
|
||||
assert manager == mock_local_manager
|
||||
|
||||
|
||||
def test_get_eval_sets_manager_gcs(monkeypatch):
|
||||
mock_gcs_manager = mock.MagicMock()
|
||||
mock_create_gcs = mock.MagicMock()
|
||||
mock_create_gcs.return_value = SimpleNamespace(
|
||||
eval_sets_manager=mock_gcs_manager
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"google.adk.cli.utils.evals.create_gcs_eval_managers_from_uri",
|
||||
mock_create_gcs,
|
||||
)
|
||||
from google.adk.cli.cli_eval import get_eval_sets_manager
|
||||
|
||||
manager = get_eval_sets_manager(
|
||||
eval_storage_uri="gs://bucket", agents_dir="some/dir"
|
||||
)
|
||||
assert manager == mock_gcs_manager
|
||||
mock_create_gcs.assert_called_once_with("gs://bucket")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,57 @@
|
||||
# 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.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from google.adk.cli.utils.dot_adk_folder import dot_adk_folder_for_agent
|
||||
from google.adk.cli.utils.dot_adk_folder import DotAdkFolder
|
||||
import pytest
|
||||
|
||||
|
||||
def test_paths_are_relative_to_agent_dir(tmp_path: Path):
|
||||
agent_dir = tmp_path / "agent_a"
|
||||
folder = DotAdkFolder(agent_dir)
|
||||
|
||||
assert folder.dot_adk_dir == agent_dir.resolve() / ".adk"
|
||||
assert folder.artifacts_dir == folder.dot_adk_dir / "artifacts"
|
||||
assert folder.session_db_path == folder.dot_adk_dir / "session.db"
|
||||
|
||||
|
||||
def test_for_agent_validates_app_name(tmp_path: Path):
|
||||
agents_root = tmp_path / "agents"
|
||||
agents_root.mkdir()
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
dot_adk_folder_for_agent(
|
||||
agents_root=agents_root, app_name="../escape_attempt"
|
||||
)
|
||||
|
||||
folder = dot_adk_folder_for_agent(
|
||||
agents_root=agents_root, app_name="valid_agent"
|
||||
)
|
||||
|
||||
expected_dir = (agents_root / "valid_agent").resolve()
|
||||
assert folder.agent_dir == expected_dir
|
||||
|
||||
|
||||
def test_for_agent_rejects_prefix_sibling_escape(tmp_path: Path):
|
||||
agents_root = tmp_path / "agents"
|
||||
agents_root.mkdir()
|
||||
|
||||
# Sibling whose path shares the agents_root string prefix ("agents" ->
|
||||
# "agents_evil"); a startswith() check would wrongly accept this.
|
||||
with pytest.raises(ValueError):
|
||||
dot_adk_folder_for_agent(agents_root=agents_root, app_name="../agents_evil")
|
||||
@@ -0,0 +1,92 @@
|
||||
# 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.
|
||||
|
||||
"""Unit tests for dotenv loading utilities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import google.adk.cli.utils.envs as envs
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_explicit_env_cache() -> None:
|
||||
envs._get_explicit_env_keys.cache_clear()
|
||||
|
||||
|
||||
def test_load_dotenv_for_agent_preserves_explicit_env(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
agents_dir = tmp_path / "agents"
|
||||
agent_dir = agents_dir / "agent1"
|
||||
agent_dir.mkdir(parents=True)
|
||||
|
||||
explicit_key = "ADK_TEST_EXPLICIT_ENV"
|
||||
from_dotenv_key = "ADK_TEST_FROM_DOTENV"
|
||||
|
||||
monkeypatch.setenv(explicit_key, "explicit")
|
||||
monkeypatch.delenv(from_dotenv_key, raising=False)
|
||||
envs._get_explicit_env_keys.cache_clear()
|
||||
|
||||
(agent_dir / ".env").write_text(
|
||||
f"{explicit_key}=from_dotenv\n{from_dotenv_key}=from_dotenv\n"
|
||||
)
|
||||
|
||||
envs.load_dotenv_for_agent("agent1", str(agents_dir))
|
||||
|
||||
assert os.environ[explicit_key] == "explicit"
|
||||
assert os.environ[from_dotenv_key] == "from_dotenv"
|
||||
|
||||
|
||||
def test_load_dotenv_for_agent_overrides_previous_dotenv(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
agents_dir = tmp_path / "agents"
|
||||
agent1_dir = agents_dir / "agent1"
|
||||
agent2_dir = agents_dir / "agent2"
|
||||
agent1_dir.mkdir(parents=True)
|
||||
agent2_dir.mkdir(parents=True)
|
||||
|
||||
key = "ADK_TEST_DOTENV_OVERRIDE"
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
(agent1_dir / ".env").write_text(f"{key}=one\n")
|
||||
envs.load_dotenv_for_agent("agent1", str(agents_dir))
|
||||
assert os.environ[key] == "one"
|
||||
|
||||
(agent2_dir / ".env").write_text(f"{key}=two\n")
|
||||
envs.load_dotenv_for_agent("agent2", str(agents_dir))
|
||||
assert os.environ[key] == "two"
|
||||
|
||||
|
||||
def test_load_dotenv_for_agent_respects_disable_flag(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
agents_dir = tmp_path / "agents"
|
||||
agent_dir = agents_dir / "agent1"
|
||||
agent_dir.mkdir(parents=True)
|
||||
|
||||
key = "ADK_TEST_DISABLE_DOTENV"
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
monkeypatch.setenv("ADK_DISABLE_LOAD_DOTENV", "1")
|
||||
envs._get_explicit_env_keys.cache_clear()
|
||||
|
||||
(agent_dir / ".env").write_text(f"{key}=from_dotenv\n")
|
||||
|
||||
envs.load_dotenv_for_agent("agent1", str(agents_dir))
|
||||
|
||||
assert key not in os.environ
|
||||
@@ -0,0 +1,63 @@
|
||||
# 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 utilities in eval."""
|
||||
|
||||
import os
|
||||
from unittest import mock
|
||||
|
||||
from google.adk.cli.utils import evals
|
||||
from google.adk.evaluation.gcs_eval_set_results_manager import GcsEvalSetResultsManager
|
||||
from google.adk.evaluation.gcs_eval_sets_manager import GcsEvalSetsManager
|
||||
import pytest
|
||||
|
||||
|
||||
@mock.patch.dict(os.environ, {'GOOGLE_CLOUD_PROJECT': 'test-project'})
|
||||
@mock.patch(
|
||||
'google.adk.evaluation.gcs_eval_set_results_manager.GcsEvalSetResultsManager',
|
||||
autospec=True,
|
||||
)
|
||||
@mock.patch(
|
||||
'google.adk.evaluation.gcs_eval_sets_manager.GcsEvalSetsManager',
|
||||
autospec=True,
|
||||
)
|
||||
def test_create_gcs_eval_managers_from_uri_success(
|
||||
mock_gcs_eval_sets_manager, mock_gcs_eval_set_results_manager
|
||||
):
|
||||
mock_gcs_eval_sets_manager.return_value = mock.MagicMock(
|
||||
spec=GcsEvalSetsManager
|
||||
)
|
||||
mock_gcs_eval_set_results_manager.return_value = mock.MagicMock(
|
||||
spec=GcsEvalSetResultsManager
|
||||
)
|
||||
|
||||
managers = evals.create_gcs_eval_managers_from_uri('gs://test-bucket')
|
||||
|
||||
assert managers is not None
|
||||
mock_gcs_eval_sets_manager.assert_called_once_with(
|
||||
bucket_name='test-bucket', project='test-project'
|
||||
)
|
||||
mock_gcs_eval_set_results_manager.assert_called_once_with(
|
||||
bucket_name='test-bucket', project='test-project'
|
||||
)
|
||||
assert managers.eval_sets_manager == mock_gcs_eval_sets_manager.return_value
|
||||
assert (
|
||||
managers.eval_set_results_manager
|
||||
== mock_gcs_eval_set_results_manager.return_value
|
||||
)
|
||||
|
||||
|
||||
def test_create_gcs_eval_managers_from_uri_failure():
|
||||
with pytest.raises(ValueError):
|
||||
evals.create_gcs_eval_managers_from_uri('unsupported-uri')
|
||||
@@ -0,0 +1,228 @@
|
||||
# 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 gcp_utils."""
|
||||
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from google.adk.cli.utils import gcp_utils
|
||||
import google.auth
|
||||
import google.auth.exceptions
|
||||
import requests
|
||||
|
||||
|
||||
class TestGcpUtils(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
patcher = mock.patch(
|
||||
"google.adk.cli.utils.gcp_utils._mtls_utils.use_client_cert_effective",
|
||||
return_value=False,
|
||||
)
|
||||
self.mock_use_client_cert_effective = patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
@mock.patch("google.auth.default")
|
||||
def test_check_adc_success(self, mock_auth_default):
|
||||
mock_auth_default.return_value = (mock.Mock(), "test-project")
|
||||
self.assertTrue(gcp_utils.check_adc())
|
||||
|
||||
@mock.patch("google.auth.default")
|
||||
def test_check_adc_failure(self, mock_auth_default):
|
||||
mock_auth_default.side_effect = (
|
||||
google.auth.exceptions.DefaultCredentialsError()
|
||||
)
|
||||
self.assertFalse(gcp_utils.check_adc())
|
||||
|
||||
@mock.patch("google.auth.default")
|
||||
def test_get_access_token(self, mock_auth_default):
|
||||
mock_creds = mock.Mock()
|
||||
mock_creds.token = "test-token"
|
||||
mock_creds.valid = True
|
||||
mock_auth_default.return_value = (mock_creds, "test-project")
|
||||
self.assertEqual(gcp_utils.get_access_token(), "test-token")
|
||||
|
||||
@mock.patch("google.adk.cli.utils.gcp_utils.AuthorizedSession")
|
||||
@mock.patch("google.auth.default")
|
||||
def test_retrieve_express_project_success(
|
||||
self, mock_auth_default, mock_session_cls
|
||||
):
|
||||
mock_auth_default.return_value = (mock.Mock(), "test-project-id")
|
||||
|
||||
mock_session = mock.Mock()
|
||||
mock_session_cls.return_value = mock_session
|
||||
mock_response = mock.Mock()
|
||||
mock_response.json.return_value = {
|
||||
"expressProject": {
|
||||
"projectId": "test-project",
|
||||
"defaultApiKey": "test-api-key",
|
||||
"region": "us-central1",
|
||||
}
|
||||
}
|
||||
mock_session.get.return_value = mock_response
|
||||
|
||||
result = gcp_utils.retrieve_express_project()
|
||||
self.assertEqual(result["project_id"], "test-project")
|
||||
self.assertEqual(result["api_key"], "test-api-key")
|
||||
self.assertEqual(result["region"], "us-central1")
|
||||
mock_session.get.assert_called_once()
|
||||
args, kwargs = mock_session.get.call_args
|
||||
self.assertEqual(
|
||||
args[0],
|
||||
"https://us-central1-aiplatform.googleapis.com/v1beta1/vertexExpress:retrieveExpressProject",
|
||||
)
|
||||
self.assertEqual(kwargs["params"], {"get_default_api_key": True})
|
||||
|
||||
@mock.patch("google.adk.cli.utils.gcp_utils.AuthorizedSession")
|
||||
@mock.patch("google.auth.default")
|
||||
def test_retrieve_express_project_not_found(
|
||||
self, mock_auth_default, mock_session_cls
|
||||
):
|
||||
mock_auth_default.return_value = (mock.Mock(), "test-project-id")
|
||||
|
||||
mock_session = mock.Mock()
|
||||
mock_session_cls.return_value = mock_session
|
||||
mock_response = mock.Mock()
|
||||
mock_response.status_code = 404
|
||||
mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError(
|
||||
response=mock_response
|
||||
)
|
||||
mock_session.get.return_value = mock_response
|
||||
|
||||
result = gcp_utils.retrieve_express_project()
|
||||
self.assertIsNone(result)
|
||||
|
||||
@mock.patch("google.adk.cli.utils.gcp_utils.AuthorizedSession")
|
||||
@mock.patch("google.auth.default")
|
||||
def test_check_express_eligibility(self, mock_auth_default, mock_session_cls):
|
||||
mock_auth_default.return_value = (mock.Mock(), "test-project-id")
|
||||
|
||||
mock_session = mock.Mock()
|
||||
mock_session_cls.return_value = mock_session
|
||||
mock_response = mock.Mock()
|
||||
mock_response.json.return_value = {"eligibility": "IN_SCOPE"}
|
||||
mock_session.get.return_value = mock_response
|
||||
|
||||
self.assertTrue(gcp_utils.check_express_eligibility())
|
||||
|
||||
@mock.patch("google.adk.cli.utils.gcp_utils.AuthorizedSession")
|
||||
@mock.patch("google.auth.default")
|
||||
def test_sign_up_express(self, mock_auth_default, mock_session_cls):
|
||||
mock_auth_default.return_value = (mock.Mock(), "test-project-id")
|
||||
|
||||
mock_session = mock.Mock()
|
||||
mock_session_cls.return_value = mock_session
|
||||
mock_response = mock.Mock()
|
||||
mock_response.json.return_value = {
|
||||
"projectId": "new-project",
|
||||
"defaultApiKey": "new-api-key",
|
||||
"region": "us-central1",
|
||||
}
|
||||
mock_session.post.return_value = mock_response
|
||||
|
||||
result = gcp_utils.sign_up_express()
|
||||
self.assertEqual(result["project_id"], "new-project")
|
||||
self.assertEqual(result["api_key"], "new-api-key")
|
||||
args, kwargs = mock_session.post.call_args
|
||||
self.assertEqual(
|
||||
args[0],
|
||||
"https://us-central1-aiplatform.googleapis.com/v1beta1/vertexExpress:signUp",
|
||||
)
|
||||
self.assertEqual(
|
||||
kwargs["json"],
|
||||
{
|
||||
"region": "us-central1",
|
||||
"tos_accepted": True,
|
||||
"get_default_api_key": True,
|
||||
},
|
||||
)
|
||||
|
||||
@mock.patch("google.cloud.resourcemanager_v3.ProjectsClient")
|
||||
def test_list_gcp_projects(self, mock_client_cls):
|
||||
mock_client = mock.Mock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
mock_project1 = mock.Mock()
|
||||
mock_project1.project_id = "p1"
|
||||
mock_project1.display_name = "Project 1"
|
||||
|
||||
mock_project2 = mock.Mock()
|
||||
mock_project2.project_id = "p2"
|
||||
mock_project2.display_name = None
|
||||
|
||||
mock_client.search_projects.return_value = [mock_project1, mock_project2]
|
||||
|
||||
projects = gcp_utils.list_gcp_projects()
|
||||
self.assertEqual(len(projects), 2)
|
||||
self.assertEqual(projects[0], ("p1", "Project 1"))
|
||||
self.assertEqual(projects[1], ("p2", "p2"))
|
||||
|
||||
@mock.patch.dict("sys.modules", {"google.cloud": None})
|
||||
def test_list_gcp_projects_import_error(self):
|
||||
with self.assertRaisesRegex(
|
||||
RuntimeError,
|
||||
"Listing GCP projects requires the 'gcp' optional dependency",
|
||||
):
|
||||
gcp_utils.list_gcp_projects()
|
||||
|
||||
@mock.patch("google.adk.cli.utils.gcp_utils.AuthorizedSession")
|
||||
@mock.patch("google.auth.default")
|
||||
def test_retrieve_express_project_mtls_enabled(
|
||||
self, mock_auth_default, mock_session_cls
|
||||
):
|
||||
# Enable mtls
|
||||
self.mock_use_client_cert_effective.return_value = True
|
||||
|
||||
mock_auth_default.return_value = (mock.Mock(), "test-project-id")
|
||||
|
||||
mock_session = mock.Mock()
|
||||
mock_session_cls.return_value = mock_session
|
||||
mock_response = mock.Mock()
|
||||
mock_response.json.return_value = {
|
||||
"expressProject": {
|
||||
"projectId": "test-project",
|
||||
"defaultApiKey": "test-api-key",
|
||||
"region": "us-central1",
|
||||
}
|
||||
}
|
||||
mock_session.get.return_value = mock_response
|
||||
|
||||
with mock.patch(
|
||||
"google.adk.cli.utils.gcp_utils._mtls_utils.get_api_endpoint",
|
||||
return_value=(
|
||||
"https://us-central1-aiplatform.mtls.googleapis.com/v1beta1"
|
||||
),
|
||||
) as mock_get_api_endpoint:
|
||||
result = gcp_utils.retrieve_express_project()
|
||||
|
||||
self.assertEqual(result["project_id"], "test-project")
|
||||
mock_session.configure_mtls_channel.assert_called_once()
|
||||
mock_get_api_endpoint.assert_called_once_with(
|
||||
location="us-central1",
|
||||
default_template="https://{location}-aiplatform.googleapis.com/v1beta1",
|
||||
mtls_template=(
|
||||
"https://{location}-aiplatform.mtls.googleapis.com/v1beta1"
|
||||
),
|
||||
)
|
||||
mock_session.get.assert_called_once()
|
||||
args, _ = mock_session.get.call_args
|
||||
self.assertEqual(
|
||||
args[0],
|
||||
"https://us-central1-aiplatform.mtls.googleapis.com/v1beta1/vertexExpress:retrieveExpressProject",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,163 @@
|
||||
# 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 graph_serialization edge handling with routing maps."""
|
||||
|
||||
import json
|
||||
|
||||
from google.adk.agents import LlmAgent
|
||||
from google.adk.cli.utils.graph_serialization import serialize_agent
|
||||
from google.adk.models.lite_llm import LiteLlm
|
||||
from google.adk.tools.base_toolset import BaseToolset
|
||||
from google.adk.workflow import START
|
||||
from google.adk.workflow import Workflow
|
||||
|
||||
from tests.unittests.workflow.workflow_testing_utils import TestingNode
|
||||
|
||||
|
||||
def test_serialize_edges_with_routing_map() -> None:
|
||||
"""Tests that routing map dicts in edges are serialized without error."""
|
||||
node_a = TestingNode(name='NodeA')
|
||||
node_b = TestingNode(name='NodeB')
|
||||
node_c = TestingNode(name='NodeC')
|
||||
|
||||
agent = Workflow(
|
||||
name='test_workflow',
|
||||
edges=[
|
||||
(START, node_a),
|
||||
(node_a, {'route_b': node_b, 'route_c': node_c}),
|
||||
],
|
||||
)
|
||||
|
||||
result = serialize_agent(agent)
|
||||
|
||||
serialized_edges = result['edges']
|
||||
assert len(serialized_edges) == 2
|
||||
|
||||
# First edge: (START, node_a) — serialized as a 2-element list.
|
||||
assert len(serialized_edges[0]) == 2
|
||||
|
||||
# Second edge: (node_a, {route: node}) — serialized as a 2-element list
|
||||
# where the second element is a dict with string keys.
|
||||
routing_map_edge = serialized_edges[1]
|
||||
assert len(routing_map_edge) == 2
|
||||
assert isinstance(routing_map_edge[1], dict)
|
||||
assert 'route_b' in routing_map_edge[1]
|
||||
assert 'route_c' in routing_map_edge[1]
|
||||
|
||||
|
||||
def test_serialize_edges_with_routing_map_int_keys() -> None:
|
||||
"""Tests that integer routing map keys are serialized as strings."""
|
||||
node_a = TestingNode(name='NodeA')
|
||||
node_b = TestingNode(name='NodeB')
|
||||
|
||||
agent = Workflow(
|
||||
name='test_workflow',
|
||||
edges=[
|
||||
(START, node_a),
|
||||
(node_a, {1: node_b}),
|
||||
],
|
||||
)
|
||||
|
||||
result = serialize_agent(agent)
|
||||
|
||||
routing_map_edge = result['edges'][1]
|
||||
# Integer keys become string keys in the serialized output.
|
||||
assert '1' in routing_map_edge[1]
|
||||
|
||||
|
||||
def test_serialize_edges_mixed_formats() -> None:
|
||||
"""Tests serialization of edges mixing tuples, Edge objects, and routing maps."""
|
||||
from google.adk.workflow import Edge
|
||||
|
||||
node_a = TestingNode(name='NodeA')
|
||||
node_b = TestingNode(name='NodeB')
|
||||
node_c = TestingNode(name='NodeC')
|
||||
node_d = TestingNode(name='NodeD')
|
||||
|
||||
agent = Workflow(
|
||||
name='test_workflow',
|
||||
edges=[
|
||||
(START, node_a),
|
||||
(node_a, {'route_b': node_b, 'route_c': node_c}),
|
||||
(node_b, node_d),
|
||||
Edge(from_node=node_c, to_node=node_d),
|
||||
],
|
||||
)
|
||||
|
||||
result = serialize_agent(agent)
|
||||
|
||||
serialized_edges = result['edges']
|
||||
assert len(serialized_edges) == 4
|
||||
|
||||
# Tuple edges are lists, Edge objects are dicts with from_node/to_node.
|
||||
assert isinstance(serialized_edges[0], list) # (START, node_a)
|
||||
assert isinstance(serialized_edges[1], list) # routing map
|
||||
assert isinstance(serialized_edges[2], list) # (node_b, node_d)
|
||||
assert isinstance(serialized_edges[3], dict) # Edge object
|
||||
assert 'from_node' in serialized_edges[3]
|
||||
|
||||
|
||||
def test_serialize_agent_with_toolset() -> None:
|
||||
"""Tests that toolsets are serialized using their class name."""
|
||||
|
||||
class MockToolset(BaseToolset):
|
||||
|
||||
async def get_tools(self, readonly_context=None):
|
||||
return []
|
||||
|
||||
class FakeAgent:
|
||||
model_fields = {'tools': None}
|
||||
|
||||
def __init__(self):
|
||||
self.tools = [MockToolset()]
|
||||
|
||||
agent = FakeAgent()
|
||||
result = serialize_agent(agent) # type: ignore
|
||||
|
||||
assert 'tools' in result
|
||||
assert len(result['tools']) == 1
|
||||
assert result['tools'][0]['name'] == 'MockToolset'
|
||||
assert result['tools'][0]['type'] == 'tool'
|
||||
|
||||
|
||||
def test_serialize_agent_with_litellm_model_is_json_safe() -> None:
|
||||
agent = LlmAgent(
|
||||
name='repro',
|
||||
model=LiteLlm(model='ollama_chat/llama3'),
|
||||
)
|
||||
|
||||
result = serialize_agent(agent)
|
||||
|
||||
assert result['model'] == 'ollama_chat/llama3'
|
||||
json.dumps(result)
|
||||
|
||||
|
||||
def test_serialize_agent_skips_excluded_fields() -> None:
|
||||
"""Fields marked Field(exclude=True) are omitted from serialization."""
|
||||
from typing import Any
|
||||
|
||||
from google.adk.agents.base_agent import BaseAgent
|
||||
from pydantic import ConfigDict
|
||||
from pydantic import Field
|
||||
|
||||
class _Agent(BaseAgent):
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
secret: Any = Field(default=None, exclude=True)
|
||||
|
||||
agent = _Agent(name='a', secret=lambda: None)
|
||||
result = serialize_agent(agent)
|
||||
|
||||
assert 'secret' not in result
|
||||
assert result['name'] == 'a'
|
||||
@@ -0,0 +1,341 @@
|
||||
# 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.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from google.adk.artifacts.file_artifact_service import FileArtifactService
|
||||
from google.adk.cli.utils.local_storage import create_local_artifact_service
|
||||
from google.adk.cli.utils.local_storage import create_local_database_session_service
|
||||
from google.adk.cli.utils.local_storage import create_local_session_service
|
||||
from google.adk.cli.utils.local_storage import PerAgentDatabaseSessionService
|
||||
from google.adk.cli.utils.local_storage import PerAgentFileArtifactService
|
||||
from google.adk.events.event import Event
|
||||
from google.adk.events.event_actions import EventActions
|
||||
from google.adk.sessions.sqlite_session_service import SqliteSessionService
|
||||
from google.genai import types
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_per_agent_session_service_creates_scoped_dot_adk(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
agent_a = tmp_path / "agent_a"
|
||||
agent_b = tmp_path / "agent_b"
|
||||
agent_a.mkdir()
|
||||
agent_b.mkdir()
|
||||
|
||||
async with PerAgentDatabaseSessionService(agents_root=tmp_path) as service:
|
||||
await service.create_session(app_name="agent_a", user_id="user_a")
|
||||
await service.create_session(app_name="agent_b", user_id="user_b")
|
||||
|
||||
assert (agent_a / ".adk" / "session.db").exists()
|
||||
assert (agent_b / ".adk" / "session.db").exists()
|
||||
|
||||
agent_a_sessions = await service.list_sessions(app_name="agent_a")
|
||||
agent_b_sessions = await service.list_sessions(app_name="agent_b")
|
||||
|
||||
assert len(agent_a_sessions.sessions) == 1
|
||||
assert agent_a_sessions.sessions[0].app_name == "agent_a"
|
||||
assert len(agent_b_sessions.sessions) == 1
|
||||
assert agent_b_sessions.sessions[0].app_name == "agent_b"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_per_agent_session_service_respects_app_name_alias(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
folder_name = "agent_folder"
|
||||
logical_name = "custom_app"
|
||||
(tmp_path / folder_name).mkdir()
|
||||
|
||||
service = create_local_session_service(
|
||||
base_dir=tmp_path,
|
||||
per_agent=True,
|
||||
app_name_to_dir={logical_name: folder_name},
|
||||
)
|
||||
try:
|
||||
session = await service.create_session(
|
||||
app_name=logical_name,
|
||||
user_id="user",
|
||||
)
|
||||
|
||||
assert session.app_name == logical_name
|
||||
assert (tmp_path / folder_name / ".adk" / "session.db").exists()
|
||||
finally:
|
||||
if isinstance(service, PerAgentDatabaseSessionService):
|
||||
await service.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_per_agent_session_service_routes_built_in_agents_to_root_dot_adk(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
async with PerAgentDatabaseSessionService(agents_root=tmp_path) as service:
|
||||
await service.create_session(app_name="__helper", user_id="user")
|
||||
|
||||
assert not (tmp_path / "__helper").exists()
|
||||
assert (tmp_path / ".adk" / "session.db").exists()
|
||||
|
||||
|
||||
def test_create_local_database_session_service_returns_sqlite(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service = create_local_database_session_service(base_dir=tmp_path)
|
||||
|
||||
assert isinstance(service, SqliteSessionService)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_per_agent_session_service_get_user_state(tmp_path: Path) -> None:
|
||||
"""Verifies get_user_state routes to correct agent and returns correct state."""
|
||||
agent_a = tmp_path / "agent_a"
|
||||
agent_b = tmp_path / "agent_b"
|
||||
agent_a.mkdir()
|
||||
agent_b.mkdir()
|
||||
|
||||
async with PerAgentDatabaseSessionService(agents_root=tmp_path) as service:
|
||||
session_a = await service.create_session(
|
||||
app_name="agent_a", user_id="user_a"
|
||||
)
|
||||
await service.append_event(
|
||||
session_a,
|
||||
Event(
|
||||
author="system",
|
||||
actions=EventActions(
|
||||
state_delta={"user:profile": {"name": "Alice"}}
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
state_a = await service.get_user_state(app_name="agent_a", user_id="user_a")
|
||||
state_b = await service.get_user_state(app_name="agent_b", user_id="user_b")
|
||||
|
||||
assert state_a == {"profile": {"name": "Alice"}}
|
||||
assert not state_b
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_per_agent_artifact_service_creates_scoped_dot_adk(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
agent_a = tmp_path / "agent_a"
|
||||
agent_b = tmp_path / "agent_b"
|
||||
agent_a.mkdir()
|
||||
agent_b.mkdir()
|
||||
|
||||
service = PerAgentFileArtifactService(agents_root=tmp_path)
|
||||
artifact = types.Part.from_bytes(data=b"data", mime_type="text/plain")
|
||||
|
||||
await service.save_artifact(
|
||||
app_name="agent_a",
|
||||
user_id="user_a",
|
||||
session_id="session_a",
|
||||
filename="file.txt",
|
||||
artifact=artifact,
|
||||
)
|
||||
await service.save_artifact(
|
||||
app_name="agent_b",
|
||||
user_id="user_b",
|
||||
session_id="session_b",
|
||||
filename="file.txt",
|
||||
artifact=artifact,
|
||||
)
|
||||
|
||||
assert (agent_a / ".adk" / "artifacts").exists()
|
||||
assert (agent_b / ".adk" / "artifacts").exists()
|
||||
assert not (tmp_path / ".adk").exists()
|
||||
|
||||
keys_a = await service.list_artifact_keys(
|
||||
app_name="agent_a", user_id="user_a", session_id="session_a"
|
||||
)
|
||||
assert keys_a == ["file.txt"]
|
||||
# agent_b's store doesn't see agent_a's artifact, even at the same scope.
|
||||
keys_from_other_agent = await service.list_artifact_keys(
|
||||
app_name="agent_b", user_id="user_a", session_id="session_a"
|
||||
)
|
||||
assert keys_from_other_agent == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_per_agent_artifact_service_respects_app_name_alias(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
folder_name = "agent_folder"
|
||||
logical_name = "custom_app"
|
||||
(tmp_path / folder_name).mkdir()
|
||||
|
||||
service = create_local_artifact_service(
|
||||
base_dir=tmp_path,
|
||||
per_agent=True,
|
||||
app_name_to_dir={logical_name: folder_name},
|
||||
)
|
||||
|
||||
await service.save_artifact(
|
||||
app_name=logical_name,
|
||||
user_id="user",
|
||||
session_id="session",
|
||||
filename="file.txt",
|
||||
artifact=types.Part.from_bytes(data=b"data", mime_type="text/plain"),
|
||||
)
|
||||
|
||||
assert (tmp_path / folder_name / ".adk" / "artifacts").exists()
|
||||
assert not (tmp_path / logical_name).exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_per_agent_artifact_service_routes_built_in_agents_to_root_dot_adk(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service = PerAgentFileArtifactService(agents_root=tmp_path)
|
||||
|
||||
await service.save_artifact(
|
||||
app_name="__helper",
|
||||
user_id="user",
|
||||
session_id="session",
|
||||
filename="file.txt",
|
||||
artifact=types.Part.from_bytes(data=b"data", mime_type="text/plain"),
|
||||
)
|
||||
|
||||
assert not (tmp_path / "__helper").exists()
|
||||
assert (tmp_path / ".adk" / "artifacts").exists()
|
||||
|
||||
|
||||
def test_create_local_artifact_service_returns_file_service(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service = create_local_artifact_service(base_dir=tmp_path)
|
||||
|
||||
assert isinstance(service, FileArtifactService)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_per_agent_artifact_service_delegates_all_operations(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
(tmp_path / "agent_a").mkdir()
|
||||
service = PerAgentFileArtifactService(agents_root=tmp_path)
|
||||
artifact = types.Part.from_bytes(data=b"data", mime_type="text/plain")
|
||||
scope = {"app_name": "agent_a", "user_id": "user", "session_id": "session"}
|
||||
|
||||
version = await service.save_artifact(
|
||||
filename="file.txt", artifact=artifact, **scope
|
||||
)
|
||||
assert version == 0
|
||||
|
||||
loaded = await service.load_artifact(filename="file.txt", **scope)
|
||||
assert loaded is not None
|
||||
assert loaded.inline_data.data == b"data"
|
||||
assert await service.list_versions(filename="file.txt", **scope) == [0]
|
||||
assert (
|
||||
len(await service.list_artifact_versions(filename="file.txt", **scope))
|
||||
== 1
|
||||
)
|
||||
assert (
|
||||
await service.get_artifact_version(filename="file.txt", **scope)
|
||||
) is not None
|
||||
|
||||
await service.delete_artifact(filename="file.txt", **scope)
|
||||
assert await service.list_artifact_keys(**scope) == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_per_agent_artifact_service_reads_legacy_shared_root(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
scope = {"app_name": "agent_a", "user_id": "user", "session_id": "session"}
|
||||
# Seed an artifact in the pre-per-agent shared <root>/.adk/artifacts store.
|
||||
legacy = FileArtifactService(root_dir=tmp_path / ".adk" / "artifacts")
|
||||
await legacy.save_artifact(
|
||||
filename="legacy.txt",
|
||||
artifact=types.Part.from_bytes(data=b"old", mime_type="text/plain"),
|
||||
**scope,
|
||||
)
|
||||
|
||||
service = PerAgentFileArtifactService(agents_root=tmp_path)
|
||||
|
||||
loaded = await service.load_artifact(filename="legacy.txt", **scope)
|
||||
assert loaded is not None
|
||||
assert loaded.inline_data.data == b"old"
|
||||
assert await service.list_artifact_keys(**scope) == ["legacy.txt"]
|
||||
assert await service.list_versions(filename="legacy.txt", **scope) == [0]
|
||||
assert (
|
||||
await service.get_artifact_version(filename="legacy.txt", **scope)
|
||||
) is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_per_agent_artifact_service_writes_do_not_touch_legacy_root(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
scope = {"app_name": "agent_a", "user_id": "user", "session_id": "session"}
|
||||
legacy = FileArtifactService(root_dir=tmp_path / ".adk" / "artifacts")
|
||||
await legacy.save_artifact(
|
||||
filename="legacy.txt",
|
||||
artifact=types.Part.from_bytes(data=b"old", mime_type="text/plain"),
|
||||
**scope,
|
||||
)
|
||||
|
||||
service = PerAgentFileArtifactService(agents_root=tmp_path)
|
||||
await service.save_artifact(
|
||||
filename="new.txt",
|
||||
artifact=types.Part.from_bytes(data=b"new", mime_type="text/plain"),
|
||||
**scope,
|
||||
)
|
||||
|
||||
# New write lands per-agent, not in the legacy shared root.
|
||||
assert (tmp_path / "agent_a" / ".adk" / "artifacts").exists()
|
||||
assert await legacy.list_artifact_keys(**scope) == ["legacy.txt"]
|
||||
# Reads union the new per-agent key with the legacy one.
|
||||
assert await service.list_artifact_keys(**scope) == ["legacy.txt", "new.txt"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_per_agent_artifact_service_no_fallback_without_legacy_dir(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service = PerAgentFileArtifactService(agents_root=tmp_path)
|
||||
|
||||
result = await service.load_artifact(
|
||||
app_name="agent_a",
|
||||
user_id="user",
|
||||
session_id="session",
|
||||
filename="missing.txt",
|
||||
)
|
||||
|
||||
assert result is None
|
||||
assert not (tmp_path / ".adk").exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_per_agent_artifact_service_delete_removes_legacy_copy(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
scope = {"app_name": "agent_a", "user_id": "user", "session_id": "session"}
|
||||
legacy = FileArtifactService(root_dir=tmp_path / ".adk" / "artifacts")
|
||||
await legacy.save_artifact(
|
||||
filename="legacy.txt",
|
||||
artifact=types.Part.from_bytes(data=b"old", mime_type="text/plain"),
|
||||
**scope,
|
||||
)
|
||||
|
||||
service = PerAgentFileArtifactService(agents_root=tmp_path)
|
||||
await service.delete_artifact(filename="legacy.txt", **scope)
|
||||
|
||||
# Deleted artifact must not reappear through the legacy read fallback.
|
||||
assert await service.load_artifact(filename="legacy.txt", **scope) is None
|
||||
assert await service.list_artifact_keys(**scope) == []
|
||||
assert await legacy.list_artifact_keys(**scope) == []
|
||||
@@ -0,0 +1,156 @@
|
||||
# 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.
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
from google.adk.cli.utils._nested_agent_loader import NestedAgentLoader
|
||||
import pytest
|
||||
|
||||
|
||||
class TestNestedAgentLoader:
|
||||
"""Comprehensive tests for NestedAgentLoader focusing on list_agents."""
|
||||
|
||||
def test_list_agents_in_single_agent_mode_returns_only_single_agent(self):
|
||||
"""Single agent mode returns list containing only the single agent's name."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_path = Path(temp_dir)
|
||||
agent_file = temp_path / "my_agent.py"
|
||||
agent_file.write_text("")
|
||||
|
||||
loader = NestedAgentLoader(str(agent_file))
|
||||
agents = loader.list_agents()
|
||||
|
||||
assert agents == ["my_agent"]
|
||||
|
||||
def test_list_agents_with_nonexistent_directory_returns_empty_list(self):
|
||||
"""Constructing with a nonexistent directory path returns an empty list."""
|
||||
loader = NestedAgentLoader("/nonexistent/directory/path")
|
||||
agents = loader.list_agents()
|
||||
|
||||
assert agents == []
|
||||
|
||||
def test_list_agents_finds_nested_agents_recursively(self):
|
||||
"""Recursive directory walk discovers all agents with nested namespaces."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_path = Path(temp_dir)
|
||||
|
||||
# Create agent_one/agent.py
|
||||
dir1 = temp_path / "agent_one"
|
||||
dir1.mkdir()
|
||||
(dir1 / "agent.py").write_text("")
|
||||
|
||||
# Create sub_dir/agent_two/root_agent.yaml
|
||||
dir2 = temp_path / "sub_dir" / "agent_two"
|
||||
dir2.mkdir(parents=True)
|
||||
(dir2 / "root_agent.yaml").write_text("")
|
||||
|
||||
# Create sub_dir/sub_sub_dir/agent_three/__init__.py
|
||||
dir3 = temp_path / "sub_dir" / "sub_sub_dir" / "agent_three"
|
||||
dir3.mkdir(parents=True)
|
||||
(dir3 / "__init__.py").write_text("root_agent = None")
|
||||
|
||||
loader = NestedAgentLoader(str(temp_path))
|
||||
agents = loader.list_agents()
|
||||
|
||||
assert agents == [
|
||||
"agent_one",
|
||||
"sub_dir.agent_two",
|
||||
"sub_dir.sub_sub_dir.agent_three",
|
||||
]
|
||||
|
||||
def test_list_agents_ignores_hidden_and_special_directories(self):
|
||||
"""Discovered agents exclude hidden directories, pycache, and temp folders."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_path = Path(temp_dir)
|
||||
|
||||
# Valid agent
|
||||
dir_valid = temp_path / "valid_agent"
|
||||
dir_valid.mkdir()
|
||||
(dir_valid / "agent.py").write_text("")
|
||||
|
||||
# Hidden agent (starts with dot)
|
||||
dir_hidden = temp_path / ".hidden_agent"
|
||||
dir_hidden.mkdir()
|
||||
(dir_hidden / "agent.py").write_text("")
|
||||
|
||||
# Special directory __pycache__
|
||||
dir_pycache = temp_path / "__pycache__"
|
||||
dir_pycache.mkdir()
|
||||
(dir_pycache / "agent.py").write_text("")
|
||||
|
||||
# Special directory tmp
|
||||
dir_tmp = temp_path / "tmp"
|
||||
dir_tmp.mkdir()
|
||||
(dir_tmp / "agent.py").write_text("")
|
||||
|
||||
loader = NestedAgentLoader(str(temp_path))
|
||||
agents = loader.list_agents()
|
||||
|
||||
assert agents == ["valid_agent"]
|
||||
|
||||
def test_list_agents_excludes_root_directory_itself(self):
|
||||
"""Root app directory itself is not listed as a nested agent sub-app."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_path = Path(temp_dir)
|
||||
|
||||
# Create agent.py at the root itself
|
||||
(temp_path / "agent.py").write_text("")
|
||||
|
||||
# Create a nested valid agent
|
||||
dir_nested = temp_path / "sub_agent"
|
||||
dir_nested.mkdir()
|
||||
(dir_nested / "agent.py").write_text("")
|
||||
|
||||
loader = NestedAgentLoader(str(temp_path))
|
||||
agents = loader.list_agents()
|
||||
|
||||
assert agents == ["sub_agent"]
|
||||
|
||||
def test_list_agents_sorts_discovered_agents_alphabetically(self):
|
||||
"""Returned list of discovered agents is sorted alphabetically."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_path = Path(temp_dir)
|
||||
|
||||
# Create agents in non-alphabetical order
|
||||
for name in ["z_agent", "a_agent", "m_agent"]:
|
||||
agent_dir = temp_path / name
|
||||
agent_dir.mkdir()
|
||||
(agent_dir / "agent.py").write_text("")
|
||||
|
||||
loader = NestedAgentLoader(str(temp_path))
|
||||
agents = loader.list_agents()
|
||||
|
||||
assert agents == ["a_agent", "m_agent", "z_agent"]
|
||||
|
||||
def test_remove_agent_from_cache_clears_module_keys_and_internal_cache(self):
|
||||
"""remove_agent_from_cache removes agent from internal cache and deletes its sys.modules keys."""
|
||||
loader = NestedAgentLoader("/dummy/path")
|
||||
|
||||
# Arrange: populate internal cache (with slash-separated path) and sys.modules (with dot-separated path)
|
||||
loader._agent_cache["foo/bar/my_agent"] = "dummy_agent_instance"
|
||||
sys.modules["foo.bar.my_agent"] = "dummy_module_instance"
|
||||
sys.modules["foo.bar.my_agent.submodule"] = "dummy_submodule_instance"
|
||||
|
||||
# Act: remove the agent from cache
|
||||
loader.remove_agent_from_cache("foo/bar/my_agent")
|
||||
|
||||
# Assert: cleared from loader's internal cache
|
||||
assert "foo/bar/my_agent" not in loader._agent_cache
|
||||
|
||||
# Assert: cleared from sys.modules (both the module and its submodules)
|
||||
assert "foo.bar.my_agent" not in sys.modules
|
||||
assert "foo.bar.my_agent.submodule" not in sys.modules
|
||||
@@ -0,0 +1,568 @@
|
||||
# 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 service factory helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from google.adk.artifacts.in_memory_artifact_service import InMemoryArtifactService
|
||||
from google.adk.cli.service_registry import ServiceRegistry
|
||||
from google.adk.cli.utils.local_storage import PerAgentDatabaseSessionService
|
||||
from google.adk.cli.utils.local_storage import PerAgentFileArtifactService
|
||||
import google.adk.cli.utils.service_factory as service_factory
|
||||
from google.adk.memory.in_memory_memory_service import InMemoryMemoryService
|
||||
from google.adk.sessions.database_session_service import DatabaseSessionService
|
||||
from google.adk.sessions.in_memory_session_service import InMemorySessionService
|
||||
from google.genai import types
|
||||
import pytest
|
||||
|
||||
|
||||
def test_create_session_service_uses_registry(tmp_path: Path, monkeypatch):
|
||||
registry = mock.create_autospec(ServiceRegistry, instance=True, spec_set=True)
|
||||
expected = object()
|
||||
registry.create_session_service.return_value = expected
|
||||
monkeypatch.setattr(service_factory, "get_service_registry", lambda: registry)
|
||||
|
||||
result = service_factory.create_session_service_from_options(
|
||||
base_dir=tmp_path,
|
||||
session_service_uri="sqlite:///test.db",
|
||||
)
|
||||
|
||||
assert result is expected
|
||||
registry.create_session_service.assert_called_once_with(
|
||||
"sqlite:///test.db",
|
||||
agents_dir=str(tmp_path),
|
||||
)
|
||||
|
||||
|
||||
def test_create_session_service_logs_redacted_uri(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
registry = mock.create_autospec(ServiceRegistry, instance=True, spec_set=True)
|
||||
registry.create_session_service.return_value = object()
|
||||
monkeypatch.setattr(service_factory, "get_service_registry", lambda: registry)
|
||||
|
||||
session_service_uri = (
|
||||
"postgresql://user:supersecret@localhost:5432/dbname?sslmode=require"
|
||||
)
|
||||
caplog.set_level(logging.INFO, logger=service_factory.logger.name)
|
||||
|
||||
service_factory.create_session_service_from_options(
|
||||
base_dir=tmp_path,
|
||||
session_service_uri=session_service_uri,
|
||||
)
|
||||
|
||||
assert "supersecret" not in caplog.text
|
||||
assert "sslmode=require" not in caplog.text
|
||||
assert "localhost:5432" in caplog.text
|
||||
|
||||
|
||||
def test_redact_uri_for_log_removes_credentials_with_at_in_password() -> None:
|
||||
uri = "postgresql://user:super@secret@localhost:5432/dbname"
|
||||
|
||||
assert (
|
||||
service_factory._redact_uri_for_log(uri)
|
||||
== "postgresql://localhost:5432/dbname"
|
||||
)
|
||||
|
||||
|
||||
def test_redact_uri_for_log_preserves_host_when_no_credentials() -> None:
|
||||
uri = "postgresql://localhost:5432/dbname?sslmode=require&password=secret"
|
||||
|
||||
redacted = service_factory._redact_uri_for_log(uri)
|
||||
|
||||
assert redacted.startswith("postgresql://localhost:5432/dbname?")
|
||||
assert "require" not in redacted
|
||||
assert "secret" not in redacted
|
||||
assert "sslmode=<redacted>" in redacted
|
||||
assert "password=<redacted>" in redacted
|
||||
|
||||
|
||||
def test_redact_uri_for_log_redacts_when_parse_qsl_fails(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
def _raise_value_error(*_args, **_kwargs):
|
||||
raise ValueError("bad query")
|
||||
|
||||
monkeypatch.setattr(service_factory, "parse_qsl", _raise_value_error)
|
||||
|
||||
uri = "postgresql://user:pass@localhost:5432/dbname?sslmode=require"
|
||||
redacted = service_factory._redact_uri_for_log(uri)
|
||||
|
||||
assert "pass" not in redacted
|
||||
assert "require" not in redacted
|
||||
assert redacted.endswith("?<redacted>")
|
||||
|
||||
|
||||
def test_redact_uri_for_log_escapes_crlf() -> None:
|
||||
uri = (
|
||||
"postgresql://user:pass@localhost:5432/dbname\rINJECT\nINJECT"
|
||||
"?sslmode=require"
|
||||
)
|
||||
|
||||
redacted = service_factory._redact_uri_for_log(uri)
|
||||
|
||||
assert "\r" not in redacted
|
||||
assert "\n" not in redacted
|
||||
assert "\\rINJECT\\nINJECT" in redacted
|
||||
|
||||
|
||||
def test_redact_uri_for_log_returns_scheme_missing_without_separator() -> None:
|
||||
assert (
|
||||
service_factory._redact_uri_for_log("user:pass@localhost:5432/dbname")
|
||||
== "<scheme-missing>"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_session_service_defaults_to_per_agent_sqlite(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
agent_dir = tmp_path / "agent_a"
|
||||
agent_dir.mkdir()
|
||||
service = service_factory.create_session_service_from_options(
|
||||
base_dir=tmp_path,
|
||||
use_local_storage=True,
|
||||
)
|
||||
|
||||
assert isinstance(service, PerAgentDatabaseSessionService)
|
||||
session = await service.create_session(app_name="agent_a", user_id="user")
|
||||
assert session.app_name == "agent_a"
|
||||
assert (agent_dir / ".adk" / "session.db").exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_session_service_respects_app_name_mapping(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
agent_dir = tmp_path / "agent_folder"
|
||||
logical_name = "custom_app"
|
||||
agent_dir.mkdir()
|
||||
|
||||
service = service_factory.create_session_service_from_options(
|
||||
base_dir=tmp_path,
|
||||
app_name_to_dir={logical_name: "agent_folder"},
|
||||
use_local_storage=True,
|
||||
)
|
||||
|
||||
assert isinstance(service, PerAgentDatabaseSessionService)
|
||||
session = await service.create_session(app_name=logical_name, user_id="user")
|
||||
assert session.app_name == logical_name
|
||||
assert (agent_dir / ".adk" / "session.db").exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_session_service_fallbacks_to_database(
|
||||
tmp_path: Path, monkeypatch
|
||||
):
|
||||
registry = mock.create_autospec(ServiceRegistry, instance=True, spec_set=True)
|
||||
registry.create_session_service.return_value = None
|
||||
monkeypatch.setattr(service_factory, "get_service_registry", lambda: registry)
|
||||
|
||||
service = service_factory.create_session_service_from_options(
|
||||
base_dir=tmp_path,
|
||||
session_service_uri="sqlite+aiosqlite:///:memory:",
|
||||
session_db_kwargs={"echo": True},
|
||||
)
|
||||
|
||||
assert isinstance(service, DatabaseSessionService)
|
||||
assert service.db_engine.url.drivername == "sqlite+aiosqlite"
|
||||
assert service.db_engine.echo is True
|
||||
registry.create_session_service.assert_called_once_with(
|
||||
"sqlite+aiosqlite:///:memory:",
|
||||
agents_dir=str(tmp_path),
|
||||
echo=True,
|
||||
)
|
||||
await service.close()
|
||||
|
||||
|
||||
def test_create_artifact_service_uses_registry(tmp_path: Path, monkeypatch):
|
||||
registry = mock.create_autospec(ServiceRegistry, instance=True, spec_set=True)
|
||||
expected = object()
|
||||
registry.create_artifact_service.return_value = expected
|
||||
monkeypatch.setattr(service_factory, "get_service_registry", lambda: registry)
|
||||
|
||||
result = service_factory.create_artifact_service_from_options(
|
||||
base_dir=tmp_path,
|
||||
artifact_service_uri="gs://bucket/path",
|
||||
)
|
||||
|
||||
assert result is expected
|
||||
registry.create_artifact_service.assert_called_once_with(
|
||||
"gs://bucket/path",
|
||||
agents_dir=str(tmp_path),
|
||||
)
|
||||
|
||||
|
||||
def test_create_artifact_service_raises_on_unknown_scheme_when_strict(
|
||||
tmp_path: Path, monkeypatch
|
||||
):
|
||||
registry = mock.create_autospec(ServiceRegistry, instance=True, spec_set=True)
|
||||
registry.create_artifact_service.return_value = None
|
||||
monkeypatch.setattr(service_factory, "get_service_registry", lambda: registry)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
service_factory.create_artifact_service_from_options(
|
||||
base_dir=tmp_path,
|
||||
artifact_service_uri="unknown://foo",
|
||||
strict_uri=True,
|
||||
)
|
||||
|
||||
|
||||
def test_create_memory_service_uses_registry(tmp_path: Path, monkeypatch):
|
||||
registry = mock.create_autospec(ServiceRegistry, instance=True, spec_set=True)
|
||||
expected = object()
|
||||
registry.create_memory_service.return_value = expected
|
||||
monkeypatch.setattr(service_factory, "get_service_registry", lambda: registry)
|
||||
|
||||
result = service_factory.create_memory_service_from_options(
|
||||
base_dir=tmp_path,
|
||||
memory_service_uri="rag://my-corpus",
|
||||
)
|
||||
|
||||
assert result is expected
|
||||
registry.create_memory_service.assert_called_once_with(
|
||||
"rag://my-corpus",
|
||||
agents_dir=str(tmp_path),
|
||||
)
|
||||
|
||||
|
||||
def test_create_memory_service_defaults_to_in_memory(tmp_path: Path):
|
||||
service = service_factory.create_memory_service_from_options(
|
||||
base_dir=tmp_path
|
||||
)
|
||||
|
||||
assert isinstance(service, InMemoryMemoryService)
|
||||
|
||||
|
||||
def test_create_memory_service_supports_memory_uri(tmp_path: Path):
|
||||
service = service_factory.create_memory_service_from_options(
|
||||
base_dir=tmp_path,
|
||||
memory_service_uri="memory://",
|
||||
)
|
||||
|
||||
assert isinstance(service, InMemoryMemoryService)
|
||||
|
||||
|
||||
def test_create_memory_service_raises_on_unknown_scheme(
|
||||
tmp_path: Path, monkeypatch
|
||||
):
|
||||
registry = mock.create_autospec(ServiceRegistry, instance=True, spec_set=True)
|
||||
registry.create_memory_service.return_value = None
|
||||
monkeypatch.setattr(service_factory, "get_service_registry", lambda: registry)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
service_factory.create_memory_service_from_options(
|
||||
base_dir=tmp_path,
|
||||
memory_service_uri="unknown://foo",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_session_service_defaults_to_in_memory_when_disabled(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service = service_factory.create_session_service_from_options(
|
||||
base_dir=tmp_path,
|
||||
use_local_storage=False,
|
||||
)
|
||||
|
||||
assert isinstance(service, InMemorySessionService)
|
||||
session = await service.create_session(app_name="agent_a", user_id="user")
|
||||
assert session.app_name == "agent_a"
|
||||
assert not (tmp_path / "agent_a" / ".adk").exists()
|
||||
|
||||
|
||||
def test_create_artifact_service_defaults_to_in_memory_when_disabled(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service = service_factory.create_artifact_service_from_options(
|
||||
base_dir=tmp_path,
|
||||
use_local_storage=False,
|
||||
)
|
||||
|
||||
assert isinstance(service, InMemoryArtifactService)
|
||||
assert not (tmp_path / ".adk").exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_artifact_service_defaults_to_per_agent(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
agent_dir = tmp_path / "agent_a"
|
||||
agent_dir.mkdir()
|
||||
service = service_factory.create_artifact_service_from_options(
|
||||
base_dir=tmp_path,
|
||||
use_local_storage=True,
|
||||
)
|
||||
|
||||
assert isinstance(service, PerAgentFileArtifactService)
|
||||
await service.save_artifact(
|
||||
app_name="agent_a",
|
||||
user_id="user",
|
||||
session_id="session",
|
||||
filename="file.txt",
|
||||
artifact=types.Part.from_bytes(data=b"data", mime_type="text/plain"),
|
||||
)
|
||||
assert (agent_dir / ".adk" / "artifacts").exists()
|
||||
assert not (tmp_path / ".adk").exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_artifact_service_respects_app_name_mapping(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
agent_dir = tmp_path / "agent_folder"
|
||||
logical_name = "custom_app"
|
||||
agent_dir.mkdir()
|
||||
|
||||
service = service_factory.create_artifact_service_from_options(
|
||||
base_dir=tmp_path,
|
||||
app_name_to_dir={logical_name: "agent_folder"},
|
||||
use_local_storage=True,
|
||||
)
|
||||
|
||||
assert isinstance(service, PerAgentFileArtifactService)
|
||||
await service.save_artifact(
|
||||
app_name=logical_name,
|
||||
user_id="user",
|
||||
session_id="session",
|
||||
filename="file.txt",
|
||||
artifact=types.Part.from_bytes(data=b"data", mime_type="text/plain"),
|
||||
)
|
||||
assert (agent_dir / ".adk" / "artifacts").exists()
|
||||
|
||||
|
||||
def test_create_artifact_service_warns_on_legacy_shared_root(
|
||||
tmp_path: Path,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
(tmp_path / ".adk" / "artifacts").mkdir(parents=True)
|
||||
|
||||
caplog.set_level(logging.WARNING, logger=service_factory.logger.name)
|
||||
service = service_factory.create_artifact_service_from_options(
|
||||
base_dir=tmp_path,
|
||||
use_local_storage=True,
|
||||
)
|
||||
|
||||
assert isinstance(service, PerAgentFileArtifactService)
|
||||
assert "legacy shared artifacts" in caplog.text
|
||||
|
||||
|
||||
def test_create_artifact_service_no_warning_without_legacy_root(
|
||||
tmp_path: Path,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
caplog.set_level(logging.WARNING, logger=service_factory.logger.name)
|
||||
service_factory.create_artifact_service_from_options(
|
||||
base_dir=tmp_path,
|
||||
use_local_storage=True,
|
||||
)
|
||||
|
||||
assert "legacy shared artifacts" not in caplog.text
|
||||
|
||||
|
||||
def test_create_session_service_fallbacks_to_in_memory_on_permission_error(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
def _raise_permission_error(*_args, **_kwargs):
|
||||
raise PermissionError("nope")
|
||||
|
||||
monkeypatch.setattr(
|
||||
service_factory, "create_local_session_service", _raise_permission_error
|
||||
)
|
||||
|
||||
service = service_factory.create_session_service_from_options(
|
||||
base_dir=tmp_path,
|
||||
use_local_storage=True,
|
||||
)
|
||||
|
||||
assert isinstance(service, InMemorySessionService)
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="chmod behavior differs on Windows")
|
||||
def test_create_services_default_to_in_memory_when_agents_dir_unwritable(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
agents_dir = tmp_path / "agents"
|
||||
agents_dir.mkdir()
|
||||
try:
|
||||
agents_dir.chmod(0o555)
|
||||
if os.access(agents_dir, os.W_OK | os.X_OK):
|
||||
pytest.skip("Test cannot make directory unwritable in this environment.")
|
||||
|
||||
session_service = service_factory.create_session_service_from_options(
|
||||
base_dir=agents_dir,
|
||||
use_local_storage=True,
|
||||
)
|
||||
assert isinstance(session_service, InMemorySessionService)
|
||||
|
||||
artifact_service = service_factory.create_artifact_service_from_options(
|
||||
base_dir=agents_dir,
|
||||
use_local_storage=True,
|
||||
)
|
||||
assert isinstance(artifact_service, InMemoryArtifactService)
|
||||
finally:
|
||||
agents_dir.chmod(0o755)
|
||||
|
||||
|
||||
def test_adk_disable_local_storage_env_forces_in_memory(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("ADK_DISABLE_LOCAL_STORAGE", "1")
|
||||
|
||||
session_service = service_factory.create_session_service_from_options(
|
||||
base_dir=tmp_path,
|
||||
use_local_storage=True,
|
||||
)
|
||||
assert isinstance(session_service, InMemorySessionService)
|
||||
|
||||
artifact_service = service_factory.create_artifact_service_from_options(
|
||||
base_dir=tmp_path,
|
||||
use_local_storage=True,
|
||||
)
|
||||
assert isinstance(artifact_service, InMemoryArtifactService)
|
||||
|
||||
|
||||
def test_cloud_run_env_defaults_to_in_memory(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("K_SERVICE", "adk-service")
|
||||
|
||||
session_service = service_factory.create_session_service_from_options(
|
||||
base_dir=tmp_path,
|
||||
use_local_storage=True,
|
||||
)
|
||||
assert isinstance(session_service, InMemorySessionService)
|
||||
|
||||
artifact_service = service_factory.create_artifact_service_from_options(
|
||||
base_dir=tmp_path,
|
||||
use_local_storage=True,
|
||||
)
|
||||
assert isinstance(artifact_service, InMemoryArtifactService)
|
||||
|
||||
|
||||
def test_kubernetes_env_defaults_to_in_memory(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("KUBERNETES_SERVICE_HOST", "10.0.0.1")
|
||||
|
||||
session_service = service_factory.create_session_service_from_options(
|
||||
base_dir=tmp_path,
|
||||
use_local_storage=True,
|
||||
)
|
||||
assert isinstance(session_service, InMemorySessionService)
|
||||
|
||||
artifact_service = service_factory.create_artifact_service_from_options(
|
||||
base_dir=tmp_path,
|
||||
use_local_storage=True,
|
||||
)
|
||||
assert isinstance(artifact_service, InMemoryArtifactService)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_adk_force_local_storage_env_overrides_flag(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("ADK_FORCE_LOCAL_STORAGE", "1")
|
||||
agent_dir = tmp_path / "agent_a"
|
||||
agent_dir.mkdir()
|
||||
|
||||
session_service = service_factory.create_session_service_from_options(
|
||||
base_dir=tmp_path,
|
||||
use_local_storage=False,
|
||||
)
|
||||
assert isinstance(session_service, PerAgentDatabaseSessionService)
|
||||
await session_service.create_session(app_name="agent_a", user_id="user")
|
||||
assert (agent_dir / ".adk" / "session.db").exists()
|
||||
|
||||
artifact_service = service_factory.create_artifact_service_from_options(
|
||||
base_dir=tmp_path,
|
||||
use_local_storage=False,
|
||||
)
|
||||
assert isinstance(artifact_service, PerAgentFileArtifactService)
|
||||
await artifact_service.save_artifact(
|
||||
app_name="agent_a",
|
||||
user_id="user",
|
||||
session_id="session",
|
||||
filename="file.txt",
|
||||
artifact=types.Part.from_bytes(data=b"data", mime_type="text/plain"),
|
||||
)
|
||||
assert (agent_dir / ".adk" / "artifacts").exists()
|
||||
|
||||
|
||||
def test_create_artifact_service_fallbacks_to_in_memory_on_permission_error(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
def _raise_permission_error(*_args, **_kwargs):
|
||||
raise PermissionError("nope")
|
||||
|
||||
monkeypatch.setattr(
|
||||
service_factory, "create_local_artifact_service", _raise_permission_error
|
||||
)
|
||||
|
||||
service = service_factory.create_artifact_service_from_options(
|
||||
base_dir=tmp_path,
|
||||
use_local_storage=True,
|
||||
)
|
||||
|
||||
assert isinstance(service, InMemoryArtifactService)
|
||||
|
||||
|
||||
def test_create_task_store_uses_registry(monkeypatch):
|
||||
registry = mock.create_autospec(ServiceRegistry, instance=True, spec_set=True)
|
||||
expected = object()
|
||||
registry._create_task_store_service.return_value = expected
|
||||
monkeypatch.setattr(service_factory, "get_service_registry", lambda: registry)
|
||||
|
||||
result = service_factory._create_task_store_from_options(
|
||||
task_store_uri="postgresql+asyncpg://user:pass@host/db",
|
||||
)
|
||||
|
||||
assert result is expected
|
||||
registry._create_task_store_service.assert_called_once_with(
|
||||
"postgresql+asyncpg://user:pass@host/db",
|
||||
)
|
||||
|
||||
|
||||
def test_create_task_store_defaults_to_in_memory():
|
||||
from a2a.server.tasks import InMemoryTaskStore
|
||||
|
||||
service = service_factory._create_task_store_from_options()
|
||||
|
||||
assert isinstance(service, InMemoryTaskStore)
|
||||
|
||||
|
||||
def test_create_task_store_raises_on_unknown_scheme(monkeypatch):
|
||||
registry = mock.create_autospec(ServiceRegistry, instance=True, spec_set=True)
|
||||
registry._create_task_store_service.side_effect = ValueError("Unsupported")
|
||||
monkeypatch.setattr(service_factory, "get_service_registry", lambda: registry)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
service_factory._create_task_store_from_options(
|
||||
task_store_uri="unknown://foo",
|
||||
)
|
||||
Reference in New Issue
Block a user