ec2b666284
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
524 lines
16 KiB
Python
524 lines
16 KiB
Python
# 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.
|
|
|
|
# Pydantic model conversion tests
|
|
|
|
from typing import Optional
|
|
from typing import Union
|
|
from unittest.mock import MagicMock
|
|
|
|
from google.adk.agents.invocation_context import InvocationContext
|
|
from google.adk.sessions.session import Session
|
|
from google.adk.tools.function_tool import FunctionTool
|
|
from google.adk.tools.tool_context import ToolContext
|
|
import pydantic
|
|
import pytest
|
|
|
|
|
|
class UserModel(pydantic.BaseModel):
|
|
"""Test Pydantic model for user data."""
|
|
|
|
name: str
|
|
age: int
|
|
email: Optional[str] = None
|
|
|
|
|
|
class PreferencesModel(pydantic.BaseModel):
|
|
"""Test Pydantic model for preferences."""
|
|
|
|
theme: str = "light"
|
|
notifications: bool = True
|
|
|
|
|
|
class CompanyModel(pydantic.BaseModel):
|
|
"""Test Pydantic model for company data."""
|
|
|
|
company_name: str
|
|
industry: str
|
|
employee_count: int
|
|
|
|
|
|
def sync_function_with_pydantic_model(user: UserModel) -> dict:
|
|
"""Sync function that takes a Pydantic model."""
|
|
return {
|
|
"name": user.name,
|
|
"age": user.age,
|
|
"email": user.email,
|
|
"type": str(type(user).__name__),
|
|
}
|
|
|
|
|
|
async def async_function_with_pydantic_model(user: UserModel) -> dict:
|
|
"""Async function that takes a Pydantic model."""
|
|
return {
|
|
"name": user.name,
|
|
"age": user.age,
|
|
"email": user.email,
|
|
"type": str(type(user).__name__),
|
|
}
|
|
|
|
|
|
def function_with_optional_pydantic_model(
|
|
user: UserModel, preferences: Optional[PreferencesModel] = None
|
|
) -> dict:
|
|
"""Function with required and optional Pydantic models."""
|
|
result = {
|
|
"user_name": user.name,
|
|
"user_type": str(type(user).__name__),
|
|
}
|
|
if preferences:
|
|
result.update({
|
|
"theme": preferences.theme,
|
|
"notifications": preferences.notifications,
|
|
"preferences_type": str(type(preferences).__name__),
|
|
})
|
|
return result
|
|
|
|
|
|
def function_with_mixed_args(
|
|
name: str, user: UserModel, count: int = 5
|
|
) -> dict:
|
|
"""Function with mixed argument types including Pydantic model."""
|
|
return {
|
|
"name": name,
|
|
"user_name": user.name,
|
|
"user_type": str(type(user).__name__),
|
|
"count": count,
|
|
}
|
|
|
|
|
|
def test_preprocess_args_with_dict_to_pydantic_conversion():
|
|
"""Test _preprocess_args converts dict to Pydantic model."""
|
|
tool = FunctionTool(sync_function_with_pydantic_model)
|
|
|
|
input_args = {
|
|
"user": {"name": "Alice", "age": 30, "email": "alice@example.com"}
|
|
}
|
|
|
|
processed_args = tool._preprocess_args(input_args)
|
|
|
|
# Check that the dict was converted to a Pydantic model
|
|
assert "user" in processed_args
|
|
user = processed_args["user"]
|
|
assert isinstance(user, UserModel)
|
|
assert user.name == "Alice"
|
|
assert user.age == 30
|
|
assert user.email == "alice@example.com"
|
|
|
|
|
|
def test_preprocess_args_with_existing_pydantic_model():
|
|
"""Test _preprocess_args leaves existing Pydantic model unchanged."""
|
|
tool = FunctionTool(sync_function_with_pydantic_model)
|
|
|
|
# Create an existing Pydantic model
|
|
existing_user = UserModel(name="Bob", age=25)
|
|
input_args = {"user": existing_user}
|
|
|
|
processed_args = tool._preprocess_args(input_args)
|
|
|
|
# Check that the existing model was not changed (same object)
|
|
assert "user" in processed_args
|
|
user = processed_args["user"]
|
|
assert user is existing_user
|
|
assert isinstance(user, UserModel)
|
|
assert user.name == "Bob"
|
|
|
|
|
|
def test_preprocess_args_with_optional_pydantic_model_none():
|
|
"""Test _preprocess_args handles None for optional Pydantic models."""
|
|
tool = FunctionTool(function_with_optional_pydantic_model)
|
|
|
|
input_args = {"user": {"name": "Charlie", "age": 35}, "preferences": None}
|
|
|
|
processed_args = tool._preprocess_args(input_args)
|
|
|
|
# Check user conversion
|
|
assert isinstance(processed_args["user"], UserModel)
|
|
assert processed_args["user"].name == "Charlie"
|
|
|
|
# Check preferences remains None
|
|
assert processed_args["preferences"] is None
|
|
|
|
|
|
def test_preprocess_args_with_optional_pydantic_model_dict():
|
|
"""Test _preprocess_args converts dict for optional Pydantic models."""
|
|
tool = FunctionTool(function_with_optional_pydantic_model)
|
|
|
|
input_args = {
|
|
"user": {"name": "Diana", "age": 28},
|
|
"preferences": {"theme": "dark", "notifications": False},
|
|
}
|
|
|
|
processed_args = tool._preprocess_args(input_args)
|
|
|
|
# Check both conversions
|
|
assert isinstance(processed_args["user"], UserModel)
|
|
assert processed_args["user"].name == "Diana"
|
|
|
|
assert isinstance(processed_args["preferences"], PreferencesModel)
|
|
assert processed_args["preferences"].theme == "dark"
|
|
assert processed_args["preferences"].notifications is False
|
|
|
|
|
|
def test_preprocess_args_with_mixed_types():
|
|
"""Test _preprocess_args handles mixed argument types correctly."""
|
|
tool = FunctionTool(function_with_mixed_args)
|
|
|
|
input_args = {
|
|
"name": "test_name",
|
|
"user": {"name": "Eve", "age": 40},
|
|
"count": 10,
|
|
}
|
|
|
|
processed_args = tool._preprocess_args(input_args)
|
|
|
|
# Check that only Pydantic model was converted
|
|
assert processed_args["name"] == "test_name" # string unchanged
|
|
assert processed_args["count"] == 10 # int unchanged
|
|
|
|
# Check Pydantic model conversion
|
|
assert isinstance(processed_args["user"], UserModel)
|
|
assert processed_args["user"].name == "Eve"
|
|
assert processed_args["user"].age == 40
|
|
|
|
|
|
def test_preprocess_args_with_invalid_data_graceful_failure():
|
|
"""Test _preprocess_args handles invalid data gracefully."""
|
|
tool = FunctionTool(sync_function_with_pydantic_model)
|
|
|
|
# Invalid data that can't be converted to UserModel
|
|
input_args = {"user": "invalid_string"} # string instead of dict/model
|
|
|
|
processed_args = tool._preprocess_args(input_args)
|
|
|
|
# Should keep original value when conversion fails
|
|
assert processed_args["user"] == "invalid_string"
|
|
|
|
|
|
def test_preprocess_args_with_non_pydantic_parameters():
|
|
"""Test _preprocess_args ignores non-Pydantic parameters."""
|
|
|
|
def simple_function(name: str, age: int) -> dict:
|
|
return {"name": name, "age": age}
|
|
|
|
tool = FunctionTool(simple_function)
|
|
|
|
input_args = {"name": "test", "age": 25}
|
|
processed_args = tool._preprocess_args(input_args)
|
|
|
|
# Should remain unchanged (no Pydantic models to convert)
|
|
assert processed_args == input_args
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_async_with_pydantic_model_conversion_sync_function():
|
|
"""Test run_async with Pydantic model conversion for sync function."""
|
|
tool = FunctionTool(sync_function_with_pydantic_model)
|
|
|
|
tool_context_mock = MagicMock(spec=ToolContext)
|
|
invocation_context_mock = MagicMock(spec=InvocationContext)
|
|
session_mock = MagicMock(spec=Session)
|
|
invocation_context_mock.session = session_mock
|
|
tool_context_mock.invocation_context = invocation_context_mock
|
|
|
|
args = {"user": {"name": "Frank", "age": 45, "email": "frank@example.com"}}
|
|
|
|
result = await tool.run_async(args=args, tool_context=tool_context_mock)
|
|
|
|
# Verify the function received a proper Pydantic model
|
|
assert result["name"] == "Frank"
|
|
assert result["age"] == 45
|
|
assert result["email"] == "frank@example.com"
|
|
assert result["type"] == "UserModel"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_async_with_pydantic_model_conversion_async_function():
|
|
"""Test run_async with Pydantic model conversion for async function."""
|
|
tool = FunctionTool(async_function_with_pydantic_model)
|
|
|
|
tool_context_mock = MagicMock(spec=ToolContext)
|
|
invocation_context_mock = MagicMock(spec=InvocationContext)
|
|
session_mock = MagicMock(spec=Session)
|
|
invocation_context_mock.session = session_mock
|
|
tool_context_mock.invocation_context = invocation_context_mock
|
|
|
|
args = {"user": {"name": "Grace", "age": 32}}
|
|
|
|
result = await tool.run_async(args=args, tool_context=tool_context_mock)
|
|
|
|
# Verify the function received a proper Pydantic model
|
|
assert result["name"] == "Grace"
|
|
assert result["age"] == 32
|
|
assert result["email"] is None # default value
|
|
assert result["type"] == "UserModel"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_async_with_optional_pydantic_models():
|
|
"""Test run_async with optional Pydantic models."""
|
|
tool = FunctionTool(function_with_optional_pydantic_model)
|
|
|
|
tool_context_mock = MagicMock(spec=ToolContext)
|
|
invocation_context_mock = MagicMock(spec=InvocationContext)
|
|
session_mock = MagicMock(spec=Session)
|
|
invocation_context_mock.session = session_mock
|
|
tool_context_mock.invocation_context = invocation_context_mock
|
|
|
|
# Test with both required and optional models
|
|
args = {
|
|
"user": {"name": "Henry", "age": 50},
|
|
"preferences": {"theme": "dark", "notifications": True},
|
|
}
|
|
|
|
result = await tool.run_async(args=args, tool_context=tool_context_mock)
|
|
|
|
assert result["user_name"] == "Henry"
|
|
assert result["user_type"] == "UserModel"
|
|
assert result["theme"] == "dark"
|
|
assert result["notifications"] is True
|
|
assert result["preferences_type"] == "PreferencesModel"
|
|
|
|
|
|
def test_preprocess_args_with_list_of_pydantic_models():
|
|
"""Test _preprocess_args converts list of dicts to list of Pydantic models."""
|
|
|
|
def function_with_list(users: list[UserModel]) -> int:
|
|
return sum(u.age for u in users)
|
|
|
|
tool = FunctionTool(function_with_list)
|
|
|
|
input_args = {
|
|
"users": [
|
|
{"name": "Alice", "age": 30},
|
|
{"name": "Bob", "age": 25},
|
|
]
|
|
}
|
|
|
|
processed_args = tool._preprocess_args(input_args)
|
|
|
|
assert isinstance(processed_args["users"], list)
|
|
assert len(processed_args["users"]) == 2
|
|
assert all(isinstance(u, UserModel) for u in processed_args["users"])
|
|
assert processed_args["users"][0].name == "Alice"
|
|
assert processed_args["users"][1].age == 25
|
|
|
|
|
|
def test_preprocess_args_with_list_of_pydantic_models_already_converted():
|
|
"""Test _preprocess_args leaves existing Pydantic model instances in list."""
|
|
|
|
def function_with_list(users: list[UserModel]) -> int:
|
|
return sum(u.age for u in users)
|
|
|
|
tool = FunctionTool(function_with_list)
|
|
|
|
existing = [UserModel(name="Alice", age=30)]
|
|
input_args = {"users": existing}
|
|
|
|
processed_args = tool._preprocess_args(input_args)
|
|
|
|
assert processed_args["users"][0] is existing[0]
|
|
|
|
|
|
def test_preprocess_args_with_list_of_primitives_unchanged():
|
|
"""Test _preprocess_args leaves list of primitives unchanged."""
|
|
|
|
def function_with_list(names: list[str], counts: list[int]) -> int:
|
|
return len(names) + sum(counts)
|
|
|
|
tool = FunctionTool(function_with_list)
|
|
|
|
input_args = {"names": ["Alice", "Bob"], "counts": [1, 2, 3]}
|
|
processed_args = tool._preprocess_args(input_args)
|
|
|
|
assert processed_args["names"] == ["Alice", "Bob"]
|
|
assert processed_args["counts"] == [1, 2, 3]
|
|
|
|
|
|
def test_preprocess_args_with_list_of_pydantic_models_empty():
|
|
"""Test _preprocess_args handles empty list for list[BaseModel]."""
|
|
|
|
def function_with_list(users: list[UserModel]) -> int:
|
|
return 0
|
|
|
|
tool = FunctionTool(function_with_list)
|
|
|
|
processed_args = tool._preprocess_args({"users": []})
|
|
|
|
assert processed_args["users"] == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_async_with_list_of_pydantic_models():
|
|
"""Test run_async end-to-end with list[BaseModel] conversion."""
|
|
|
|
def place_order(orders: list[UserModel]) -> int:
|
|
return sum(u.age for u in orders)
|
|
|
|
tool = FunctionTool(place_order)
|
|
|
|
tool_context_mock = MagicMock(spec=ToolContext)
|
|
invocation_context_mock = MagicMock(spec=InvocationContext)
|
|
session_mock = MagicMock(spec=Session)
|
|
invocation_context_mock.session = session_mock
|
|
tool_context_mock.invocation_context = invocation_context_mock
|
|
|
|
args = {"orders": [{"name": "Alice", "age": 30}, {"name": "Bob", "age": 20}]}
|
|
|
|
result = await tool.run_async(args=args, tool_context=tool_context_mock)
|
|
|
|
assert result == 50
|
|
|
|
|
|
def _function_with_union_of_basemodels(
|
|
entity: Union[UserModel, CompanyModel],
|
|
) -> str:
|
|
return type(entity).__name__
|
|
|
|
|
|
def test_preprocess_args_with_union_of_basemodels_picks_user():
|
|
"""Dict matching UserModel is converted to UserModel."""
|
|
tool = FunctionTool(_function_with_union_of_basemodels)
|
|
|
|
processed_args = tool._preprocess_args(
|
|
{"entity": {"name": "Diana", "age": 32, "email": "d@example.com"}}
|
|
)
|
|
|
|
assert isinstance(processed_args["entity"], UserModel)
|
|
assert processed_args["entity"].name == "Diana"
|
|
|
|
|
|
def test_preprocess_args_with_union_of_basemodels_picks_company():
|
|
"""Dict matching CompanyModel is converted to CompanyModel."""
|
|
tool = FunctionTool(_function_with_union_of_basemodels)
|
|
|
|
processed_args = tool._preprocess_args({
|
|
"entity": {
|
|
"company_name": "Acme Corp",
|
|
"industry": "tech",
|
|
"employee_count": 50,
|
|
}
|
|
})
|
|
|
|
assert isinstance(processed_args["entity"], CompanyModel)
|
|
assert processed_args["entity"].company_name == "Acme Corp"
|
|
|
|
|
|
def test_preprocess_args_with_union_of_basemodels_existing_instance_unchanged():
|
|
"""Existing instance of any union member is left unchanged."""
|
|
tool = FunctionTool(_function_with_union_of_basemodels)
|
|
|
|
user = UserModel(name="Bob", age=25)
|
|
assert tool._preprocess_args({"entity": user})["entity"] is user
|
|
|
|
company = CompanyModel(
|
|
company_name="Acme", industry="tech", employee_count=10
|
|
)
|
|
assert tool._preprocess_args({"entity": company})["entity"] is company
|
|
|
|
|
|
def test_preprocess_args_with_union_of_basemodels_unrelated_instance_passthrough():
|
|
"""A BaseModel instance not in the union is not silently accepted."""
|
|
tool = FunctionTool(_function_with_union_of_basemodels)
|
|
|
|
class UnrelatedModel(pydantic.BaseModel):
|
|
name: str
|
|
age: int
|
|
|
|
unrelated = UnrelatedModel(name="Carol", age=20)
|
|
processed_args = tool._preprocess_args({"entity": unrelated})
|
|
|
|
# Conversion fails (UnrelatedModel is not in the union); value is left
|
|
# alone so the function receives it and raises a clear error itself.
|
|
assert processed_args["entity"] is unrelated
|
|
|
|
|
|
def test_preprocess_args_with_optional_union_of_basemodels_none():
|
|
"""Optional[Union[A, B]] passes None through unchanged."""
|
|
|
|
def fn(entity: Optional[Union[UserModel, CompanyModel]] = None) -> str:
|
|
return type(entity).__name__
|
|
|
|
tool = FunctionTool(fn)
|
|
|
|
processed_args = tool._preprocess_args({"entity": None})
|
|
|
|
assert processed_args["entity"] is None
|
|
|
|
|
|
def test_preprocess_args_with_optional_union_of_basemodels_dict():
|
|
"""Optional[Union[A, B]] converts a dict to the matching model."""
|
|
|
|
def fn(entity: Optional[Union[UserModel, CompanyModel]] = None) -> str:
|
|
return type(entity).__name__
|
|
|
|
tool = FunctionTool(fn)
|
|
|
|
processed_args = tool._preprocess_args({"entity": {"name": "Eve", "age": 40}})
|
|
|
|
assert isinstance(processed_args["entity"], UserModel)
|
|
assert processed_args["entity"].name == "Eve"
|
|
|
|
|
|
def test_preprocess_args_with_union_of_basemodels_invalid_data():
|
|
"""Invalid data for Union[BaseModel, BaseModel] is kept unchanged."""
|
|
tool = FunctionTool(_function_with_union_of_basemodels)
|
|
|
|
# Dict matches neither model.
|
|
processed_args = tool._preprocess_args(
|
|
{"entity": {"unrelated_field": "value"}}
|
|
)
|
|
|
|
assert processed_args["entity"] == {"unrelated_field": "value"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_async_with_union_of_basemodels():
|
|
"""run_async end-to-end converts dict to the matching union member."""
|
|
|
|
def create_entity_profile(
|
|
entity: Union[UserModel, CompanyModel],
|
|
) -> dict:
|
|
if isinstance(entity, UserModel):
|
|
return {"entity_type": "user", "name": entity.name}
|
|
if isinstance(entity, CompanyModel):
|
|
return {"entity_type": "company", "name": entity.company_name}
|
|
return {"entity_type": "unknown"}
|
|
|
|
tool = FunctionTool(create_entity_profile)
|
|
|
|
tool_context_mock = MagicMock(spec=ToolContext)
|
|
invocation_context_mock = MagicMock(spec=InvocationContext)
|
|
session_mock = MagicMock(spec=Session)
|
|
invocation_context_mock.session = session_mock
|
|
tool_context_mock.invocation_context = invocation_context_mock
|
|
|
|
user_result = await tool.run_async(
|
|
args={"entity": {"name": "Diana", "age": 32}},
|
|
tool_context=tool_context_mock,
|
|
)
|
|
assert user_result == {"entity_type": "user", "name": "Diana"}
|
|
|
|
company_result = await tool.run_async(
|
|
args={
|
|
"entity": {
|
|
"company_name": "Acme Corp",
|
|
"industry": "tech",
|
|
"employee_count": 50,
|
|
}
|
|
},
|
|
tool_context=tool_context_mock,
|
|
)
|
|
assert company_result == {"entity_type": "company", "name": "Acme Corp"}
|