mirror of
https://github.com/encounter/adk-python.git
synced 2026-07-09 18:19:28 -07:00
Merge branch 'main' into fix/missing-path-level-parameters
This commit is contained in:
@@ -14,7 +14,9 @@
|
||||
|
||||
import base64
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import patch
|
||||
|
||||
from google.adk.tools.apihub_tool.clients.apihub_client import APIHubClient
|
||||
import pytest
|
||||
from requests.exceptions import HTTPError
|
||||
@@ -464,9 +466,7 @@ class TestAPIHubClient:
|
||||
MagicMock(
|
||||
status_code=200,
|
||||
json=lambda: {
|
||||
"name": (
|
||||
"projects/test-project/locations/us-central1/apis/api1/versions/v1"
|
||||
),
|
||||
"name": "projects/test-project/locations/us-central1/apis/api1/versions/v1",
|
||||
"specs": [],
|
||||
},
|
||||
), # No specs
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# Copyright 2025 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,106 @@
|
||||
# Copyright 2025 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.mock import Mock
|
||||
|
||||
from google.adk.tools.bigquery.bigquery_credentials import BigQueryCredentialsConfig
|
||||
# Mock the Google OAuth and API dependencies
|
||||
from google.oauth2.credentials import Credentials
|
||||
import pytest
|
||||
|
||||
|
||||
class TestBigQueryCredentials:
|
||||
"""Test suite for BigQueryCredentials configuration validation.
|
||||
|
||||
This class tests the credential configuration logic that ensures
|
||||
either existing credentials or client ID/secret pairs are provided.
|
||||
"""
|
||||
|
||||
def test_valid_credentials_object(self):
|
||||
"""Test that providing valid Credentials object works correctly.
|
||||
|
||||
When a user already has valid OAuth credentials, they should be able
|
||||
to pass them directly without needing to provide client ID/secret.
|
||||
"""
|
||||
# Create a mock credentials object with the expected attributes
|
||||
mock_creds = Mock(spec=Credentials)
|
||||
mock_creds.client_id = "test_client_id"
|
||||
mock_creds.client_secret = "test_client_secret"
|
||||
mock_creds.scopes = ["https://www.googleapis.com/auth/calendar"]
|
||||
|
||||
config = BigQueryCredentialsConfig(credentials=mock_creds)
|
||||
|
||||
# Verify that the credentials are properly stored and attributes are extracted
|
||||
assert config.credentials == mock_creds
|
||||
assert config.client_id == "test_client_id"
|
||||
assert config.client_secret == "test_client_secret"
|
||||
assert config.scopes == ["https://www.googleapis.com/auth/calendar"]
|
||||
|
||||
def test_valid_client_id_secret_pair(self):
|
||||
"""Test that providing client ID and secret without credentials works.
|
||||
|
||||
This tests the scenario where users want to create new OAuth credentials
|
||||
from scratch using their application's client ID and secret.
|
||||
"""
|
||||
config = BigQueryCredentialsConfig(
|
||||
client_id="test_client_id",
|
||||
client_secret="test_client_secret",
|
||||
scopes=["https://www.googleapis.com/auth/bigquery"],
|
||||
)
|
||||
|
||||
assert config.credentials is None
|
||||
assert config.client_id == "test_client_id"
|
||||
assert config.client_secret == "test_client_secret"
|
||||
assert config.scopes == ["https://www.googleapis.com/auth/bigquery"]
|
||||
|
||||
def test_missing_client_secret_raises_error(self):
|
||||
"""Test that missing client secret raises appropriate validation error.
|
||||
|
||||
This ensures that incomplete OAuth configuration is caught early
|
||||
rather than failing during runtime.
|
||||
"""
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=(
|
||||
"Must provide either credentials or client_id abd client_secret"
|
||||
" pair"
|
||||
),
|
||||
):
|
||||
BigQueryCredentialsConfig(client_id="test_client_id")
|
||||
|
||||
def test_missing_client_id_raises_error(self):
|
||||
"""Test that missing client ID raises appropriate validation error."""
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=(
|
||||
"Must provide either credentials or client_id abd client_secret"
|
||||
" pair"
|
||||
),
|
||||
):
|
||||
BigQueryCredentialsConfig(client_secret="test_client_secret")
|
||||
|
||||
def test_empty_configuration_raises_error(self):
|
||||
"""Test that completely empty configuration is rejected.
|
||||
|
||||
Users must provide either existing credentials or the components
|
||||
needed to create new ones.
|
||||
"""
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=(
|
||||
"Must provide either credentials or client_id abd client_secret"
|
||||
" pair"
|
||||
),
|
||||
):
|
||||
BigQueryCredentialsConfig()
|
||||
@@ -0,0 +1,428 @@
|
||||
# Copyright 2025 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 json
|
||||
from unittest.mock import Mock
|
||||
from unittest.mock import patch
|
||||
|
||||
from google.adk.auth import AuthConfig
|
||||
from google.adk.tools import ToolContext
|
||||
from google.adk.tools.bigquery.bigquery_credentials import BIGQUERY_TOKEN_CACHE_KEY
|
||||
from google.adk.tools.bigquery.bigquery_credentials import BigQueryCredentialsConfig
|
||||
from google.adk.tools.bigquery.bigquery_credentials import BigQueryCredentialsManager
|
||||
from google.auth.exceptions import RefreshError
|
||||
# Mock the Google OAuth and API dependencies
|
||||
from google.oauth2.credentials import Credentials
|
||||
import pytest
|
||||
|
||||
|
||||
class TestBigQueryCredentialsManager:
|
||||
"""Test suite for BigQueryCredentialsManager OAuth flow handling.
|
||||
|
||||
This class tests the complex credential management logic including
|
||||
credential validation, refresh, OAuth flow orchestration, and the
|
||||
new token caching functionality through tool_context.state.
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_tool_context(self):
|
||||
"""Create a mock ToolContext for testing.
|
||||
|
||||
The ToolContext is the interface between tools and the broader
|
||||
agent framework, handling OAuth flows and state management.
|
||||
Now includes state dictionary for testing caching behavior.
|
||||
"""
|
||||
context = Mock(spec=ToolContext)
|
||||
context.get_auth_response = Mock(return_value=None)
|
||||
context.request_credential = Mock()
|
||||
context.state = {}
|
||||
return context
|
||||
|
||||
@pytest.fixture
|
||||
def credentials_config(self):
|
||||
"""Create a basic credentials configuration for testing."""
|
||||
return BigQueryCredentialsConfig(
|
||||
client_id="test_client_id",
|
||||
client_secret="test_client_secret",
|
||||
scopes=["https://www.googleapis.com/auth/calendar"],
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def manager(self, credentials_config):
|
||||
"""Create a credentials manager instance for testing."""
|
||||
return BigQueryCredentialsManager(credentials_config)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_valid_credentials_with_valid_existing_creds(
|
||||
self, manager, mock_tool_context
|
||||
):
|
||||
"""Test that valid existing credentials are returned immediately.
|
||||
|
||||
When credentials are already valid, no refresh or OAuth flow
|
||||
should be needed. This is the optimal happy path scenario.
|
||||
"""
|
||||
# Create mock credentials that are already valid
|
||||
mock_creds = Mock(spec=Credentials)
|
||||
mock_creds.valid = True
|
||||
manager.credentials_config.credentials = mock_creds
|
||||
|
||||
result = await manager.get_valid_credentials(mock_tool_context)
|
||||
|
||||
assert result == mock_creds
|
||||
# Verify no OAuth flow was triggered
|
||||
mock_tool_context.get_auth_response.assert_not_called()
|
||||
mock_tool_context.request_credential.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_credentials_from_cache_when_none_in_manager(
|
||||
self, manager, mock_tool_context
|
||||
):
|
||||
"""Test retrieving credentials from tool_context cache when manager has none.
|
||||
|
||||
This tests the new caching functionality where credentials can be
|
||||
retrieved from the tool context state when the manager instance
|
||||
doesn't have them loaded.
|
||||
"""
|
||||
# Manager starts with no credentials
|
||||
manager.credentials_config.credentials = None
|
||||
|
||||
# Create mock cached credentials JSON that would be stored in cache
|
||||
mock_cached_creds_json = json.dumps({
|
||||
"token": "cached_token",
|
||||
"refresh_token": "cached_refresh_token",
|
||||
"client_id": "test_client_id",
|
||||
"client_secret": "test_client_secret",
|
||||
})
|
||||
|
||||
# Set up the tool context state to contain cached credentials
|
||||
mock_tool_context.state[BIGQUERY_TOKEN_CACHE_KEY] = mock_cached_creds_json
|
||||
|
||||
# Mock the Credentials.from_authorized_user_info method
|
||||
with patch(
|
||||
"google.oauth2.credentials.Credentials.from_authorized_user_info"
|
||||
) as mock_from_json:
|
||||
mock_creds = Mock(spec=Credentials)
|
||||
mock_creds.valid = True
|
||||
mock_from_json.return_value = mock_creds
|
||||
|
||||
result = await manager.get_valid_credentials(mock_tool_context)
|
||||
|
||||
# Verify credentials were created from cached JSON
|
||||
mock_from_json.assert_called_once_with(
|
||||
json.loads(mock_cached_creds_json), manager.credentials_config.scopes
|
||||
)
|
||||
# Verify loaded credentials were not cached into manager
|
||||
assert manager.credentials_config.credentials is None
|
||||
# Verify valid cached credentials were returned
|
||||
assert result == mock_creds
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_credentials_in_manager_or_cache(
|
||||
self, manager, mock_tool_context
|
||||
):
|
||||
"""Test OAuth flow when no credentials exist in manager or cache.
|
||||
|
||||
This tests the scenario where both the manager and cache are empty,
|
||||
requiring a new OAuth flow to be initiated.
|
||||
"""
|
||||
# Manager starts with no credentials
|
||||
manager.credentials_config.credentials = None
|
||||
# Cache is also empty (state dict doesn't contain the key)
|
||||
|
||||
result = await manager.get_valid_credentials(mock_tool_context)
|
||||
|
||||
# Should trigger OAuth flow and return None (flow in progress)
|
||||
assert result is None
|
||||
mock_tool_context.request_credential.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("google.auth.transport.requests.Request")
|
||||
async def test_refresh_cached_credentials_success(
|
||||
self, mock_request_class, manager, mock_tool_context
|
||||
):
|
||||
"""Test successful refresh of expired credentials retrieved from cache.
|
||||
|
||||
This tests the interaction between caching and refresh functionality,
|
||||
ensuring that expired cached credentials can be refreshed properly.
|
||||
"""
|
||||
# Manager starts with no default credentials
|
||||
manager.credentials_config.credentials = None
|
||||
|
||||
# Create mock cached credentials JSON
|
||||
mock_cached_creds_json = json.dumps({
|
||||
"token": "expired_token",
|
||||
"refresh_token": "valid_refresh_token",
|
||||
"client_id": "test_client_id",
|
||||
"client_secret": "test_client_secret",
|
||||
})
|
||||
|
||||
mock_refreshed_creds_json = json.dumps({
|
||||
"token": "new_token",
|
||||
"refresh_token": "valid_refresh_token",
|
||||
"client_id": "test_client_id",
|
||||
"client_secret": "test_client_secret",
|
||||
})
|
||||
|
||||
# Set up the tool context state to contain cached credentials
|
||||
mock_tool_context.state[BIGQUERY_TOKEN_CACHE_KEY] = mock_cached_creds_json
|
||||
|
||||
# Create expired cached credentials with refresh token
|
||||
mock_cached_creds = Mock(spec=Credentials)
|
||||
mock_cached_creds.valid = False
|
||||
mock_cached_creds.expired = True
|
||||
mock_cached_creds.refresh_token = "valid_refresh_token"
|
||||
mock_cached_creds.to_json.return_value = mock_refreshed_creds_json
|
||||
|
||||
# Mock successful refresh
|
||||
def mock_refresh(request):
|
||||
mock_cached_creds.valid = True
|
||||
|
||||
mock_cached_creds.refresh = Mock(side_effect=mock_refresh)
|
||||
|
||||
# Mock the Credentials.from_authorized_user_info method
|
||||
with patch(
|
||||
"google.oauth2.credentials.Credentials.from_authorized_user_info"
|
||||
) as mock_from_json:
|
||||
mock_from_json.return_value = mock_cached_creds
|
||||
|
||||
result = await manager.get_valid_credentials(mock_tool_context)
|
||||
|
||||
# Verify credentials were created from cached JSON
|
||||
mock_from_json.assert_called_once_with(
|
||||
json.loads(mock_cached_creds_json), manager.credentials_config.scopes
|
||||
)
|
||||
# Verify refresh was attempted and succeeded
|
||||
mock_cached_creds.refresh.assert_called_once()
|
||||
# Verify refreshed credentials were not cached into manager
|
||||
assert manager.credentials_config.credentials is None
|
||||
# Verify refreshed credentials were cached
|
||||
assert (
|
||||
"new_token"
|
||||
== json.loads(mock_tool_context.state[BIGQUERY_TOKEN_CACHE_KEY])[
|
||||
"token"
|
||||
]
|
||||
)
|
||||
assert result == mock_cached_creds
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("google.auth.transport.requests.Request")
|
||||
async def test_get_valid_credentials_with_refresh_success(
|
||||
self, mock_request_class, manager, mock_tool_context
|
||||
):
|
||||
"""Test successful credential refresh when tokens are expired.
|
||||
|
||||
This tests the automatic token refresh capability that prevents
|
||||
users from having to re-authenticate for every expired token.
|
||||
"""
|
||||
# Create expired credentials with refresh token
|
||||
mock_creds = Mock(spec=Credentials)
|
||||
mock_creds.valid = False
|
||||
mock_creds.expired = True
|
||||
mock_creds.refresh_token = "refresh_token"
|
||||
|
||||
# Mock successful refresh
|
||||
def mock_refresh(request):
|
||||
mock_creds.valid = True
|
||||
|
||||
mock_creds.refresh = Mock(side_effect=mock_refresh)
|
||||
manager.credentials_config.credentials = mock_creds
|
||||
|
||||
result = await manager.get_valid_credentials(mock_tool_context)
|
||||
|
||||
assert result == mock_creds
|
||||
mock_creds.refresh.assert_called_once()
|
||||
# Verify credentials were cached after successful refresh
|
||||
assert manager.credentials_config.credentials == mock_creds
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("google.auth.transport.requests.Request")
|
||||
async def test_get_valid_credentials_with_refresh_failure(
|
||||
self, mock_request_class, manager, mock_tool_context
|
||||
):
|
||||
"""Test OAuth flow trigger when credential refresh fails.
|
||||
|
||||
When refresh tokens expire or become invalid, the system should
|
||||
gracefully fall back to requesting a new OAuth flow.
|
||||
"""
|
||||
# Create expired credentials that fail to refresh
|
||||
mock_creds = Mock(spec=Credentials)
|
||||
mock_creds.valid = False
|
||||
mock_creds.expired = True
|
||||
mock_creds.refresh_token = "expired_refresh_token"
|
||||
mock_creds.refresh = Mock(side_effect=RefreshError("Refresh failed"))
|
||||
manager.credentials_config.credentials = mock_creds
|
||||
|
||||
result = await manager.get_valid_credentials(mock_tool_context)
|
||||
|
||||
# Should trigger OAuth flow and return None (flow in progress)
|
||||
assert result is None
|
||||
mock_tool_context.request_credential.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_oauth_flow_completion_with_caching(
|
||||
self, manager, mock_tool_context
|
||||
):
|
||||
"""Test successful OAuth flow completion with proper credential caching.
|
||||
|
||||
This tests the happy path where a user completes the OAuth flow
|
||||
and the system successfully creates and caches new credentials
|
||||
in both the manager and the tool context state.
|
||||
"""
|
||||
# Mock OAuth response indicating completed flow
|
||||
mock_auth_response = Mock()
|
||||
mock_auth_response.oauth2.access_token = "new_access_token"
|
||||
mock_auth_response.oauth2.refresh_token = "new_refresh_token"
|
||||
mock_tool_context.get_auth_response.return_value = mock_auth_response
|
||||
|
||||
# Create a mock credentials instance that will represent our created credentials
|
||||
mock_creds = Mock(spec=Credentials)
|
||||
# Make the JSON match what a real Credentials object would produce
|
||||
mock_creds_json = (
|
||||
'{"token": "new_access_token", "refresh_token": "new_refresh_token",'
|
||||
' "token_uri": "https://oauth2.googleapis.com/token", "client_id":'
|
||||
' "test_client_id", "client_secret": "test_client_secret", "scopes":'
|
||||
' ["https://www.googleapis.com/auth/calendar"], "universe_domain":'
|
||||
' "googleapis.com", "account": ""}'
|
||||
)
|
||||
mock_creds.to_json.return_value = mock_creds_json
|
||||
|
||||
# Use the full module path as it appears in the project structure
|
||||
with patch(
|
||||
"google.adk.tools.bigquery.bigquery_credentials.Credentials",
|
||||
return_value=mock_creds,
|
||||
) as mock_credentials_class:
|
||||
result = await manager.get_valid_credentials(mock_tool_context)
|
||||
|
||||
# Verify new credentials were created
|
||||
assert result == mock_creds
|
||||
# Verify credentials are created with correct parameters
|
||||
mock_credentials_class.assert_called_once()
|
||||
call_kwargs = mock_credentials_class.call_args[1]
|
||||
assert call_kwargs["token"] == "new_access_token"
|
||||
assert call_kwargs["refresh_token"] == "new_refresh_token"
|
||||
|
||||
# Verify credentials are not cached in manager
|
||||
assert manager.credentials_config.credentials is None
|
||||
# Verify credentials are also cached in tool context state
|
||||
assert (
|
||||
mock_tool_context.state[BIGQUERY_TOKEN_CACHE_KEY] == mock_creds_json
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_oauth_flow_in_progress(self, manager, mock_tool_context):
|
||||
"""Test OAuth flow initiation when no auth response is available.
|
||||
|
||||
This tests the case where the OAuth flow needs to be started,
|
||||
and the user hasn't completed authorization yet.
|
||||
"""
|
||||
# No existing credentials, no auth response (flow not completed)
|
||||
manager.credentials_config.credentials = None
|
||||
mock_tool_context.get_auth_response.return_value = None
|
||||
|
||||
result = await manager.get_valid_credentials(mock_tool_context)
|
||||
|
||||
# Should return None and request credential flow
|
||||
assert result is None
|
||||
mock_tool_context.request_credential.assert_called_once()
|
||||
|
||||
# Verify the auth configuration includes correct scopes and endpoints
|
||||
call_args = mock_tool_context.request_credential.call_args[0][0]
|
||||
assert isinstance(call_args, AuthConfig)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_persistence_across_manager_instances(
|
||||
self, credentials_config, mock_tool_context
|
||||
):
|
||||
"""Test that cached credentials persist across different manager instances.
|
||||
|
||||
This tests the key benefit of the tool context caching - that
|
||||
credentials can be shared between different instances of the
|
||||
credential manager, avoiding redundant OAuth flows.
|
||||
"""
|
||||
# Create first manager instance and simulate OAuth completion
|
||||
manager1 = BigQueryCredentialsManager(credentials_config)
|
||||
|
||||
# Mock OAuth response for first manager
|
||||
mock_auth_response = Mock()
|
||||
mock_auth_response.oauth2.access_token = "cached_access_token"
|
||||
mock_auth_response.oauth2.refresh_token = "cached_refresh_token"
|
||||
mock_tool_context.get_auth_response.return_value = mock_auth_response
|
||||
|
||||
# Create the mock credentials instance that will be returned by the constructor
|
||||
mock_creds = Mock(spec=Credentials)
|
||||
# Make sure our mock JSON matches the structure that real Credentials objects produce
|
||||
mock_creds_json = (
|
||||
'{"token": "cached_access_token", "refresh_token":'
|
||||
' "cached_refresh_token", "token_uri":'
|
||||
' "https://oauth2.googleapis.com/token", "client_id": "test_client_id",'
|
||||
' "client_secret": "test_client_secret", "scopes":'
|
||||
' ["https://www.googleapis.com/auth/calendar"], "universe_domain":'
|
||||
' "googleapis.com", "account": ""}'
|
||||
)
|
||||
mock_creds.to_json.return_value = mock_creds_json
|
||||
mock_creds.valid = True
|
||||
|
||||
# Use the correct module path - without the 'src.' prefix
|
||||
with patch(
|
||||
"google.adk.tools.bigquery.bigquery_credentials.Credentials",
|
||||
return_value=mock_creds,
|
||||
) as mock_credentials_class:
|
||||
# Complete OAuth flow with first manager
|
||||
result1 = await manager1.get_valid_credentials(mock_tool_context)
|
||||
|
||||
# Verify credentials were cached in tool context
|
||||
assert BIGQUERY_TOKEN_CACHE_KEY in mock_tool_context.state
|
||||
cached_creds_json = mock_tool_context.state[BIGQUERY_TOKEN_CACHE_KEY]
|
||||
assert cached_creds_json == mock_creds_json
|
||||
|
||||
# Create second manager instance (simulating new request/session)
|
||||
manager2 = BigQueryCredentialsManager(credentials_config)
|
||||
credentials_config.credentials = None
|
||||
|
||||
# Reset auth response to None (no new OAuth flow available)
|
||||
mock_tool_context.get_auth_response.return_value = None
|
||||
|
||||
# Mock the from_authorized_user_info method for the second manager
|
||||
with patch(
|
||||
"google.adk.tools.bigquery.bigquery_credentials.Credentials.from_authorized_user_info"
|
||||
) as mock_from_json:
|
||||
mock_cached_creds = Mock(spec=Credentials)
|
||||
mock_cached_creds.valid = True
|
||||
mock_from_json.return_value = mock_cached_creds
|
||||
|
||||
# Get credentials with second manager
|
||||
result2 = await manager2.get_valid_credentials(mock_tool_context)
|
||||
|
||||
# Verify second manager retrieved cached credentials successfully
|
||||
assert result2 == mock_cached_creds
|
||||
assert manager2.credentials_config.credentials is None
|
||||
assert (
|
||||
cached_creds_json == mock_tool_context.state[BIGQUERY_TOKEN_CACHE_KEY]
|
||||
)
|
||||
# The from_authorized_user_info should be called with the complete JSON structure
|
||||
mock_from_json.assert_called_once()
|
||||
# Extract the actual argument that was passed to verify it's the right JSON structure
|
||||
actual_json_arg = mock_from_json.call_args[0][0]
|
||||
# We need to parse and compare the structure rather than exact string match
|
||||
# since the order of keys in JSON might differ
|
||||
import json
|
||||
|
||||
expected_data = json.loads(mock_creds_json)
|
||||
actual_data = (
|
||||
actual_json_arg
|
||||
if isinstance(actual_json_arg, dict)
|
||||
else json.loads(actual_json_arg)
|
||||
)
|
||||
assert actual_data == expected_data
|
||||
@@ -0,0 +1,259 @@
|
||||
# Copyright 2025 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.mock import Mock
|
||||
from unittest.mock import patch
|
||||
|
||||
from google.adk.tools import ToolContext
|
||||
from google.adk.tools.bigquery.bigquery_credentials import BigQueryCredentialsConfig
|
||||
from google.adk.tools.bigquery.bigquery_credentials import BigQueryCredentialsManager
|
||||
from google.adk.tools.bigquery.bigquery_tool import BigQueryTool
|
||||
# Mock the Google OAuth and API dependencies
|
||||
from google.oauth2.credentials import Credentials
|
||||
import pytest
|
||||
|
||||
|
||||
class TestBigQueryTool:
|
||||
"""Test suite for BigQueryTool OAuth integration and execution.
|
||||
|
||||
This class tests the high-level tool execution logic that combines
|
||||
credential management with actual function execution.
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_tool_context(self):
|
||||
"""Create a mock ToolContext for testing tool execution."""
|
||||
context = Mock(spec=ToolContext)
|
||||
context.get_auth_response = Mock(return_value=None)
|
||||
context.request_credential = Mock()
|
||||
return context
|
||||
|
||||
@pytest.fixture
|
||||
def sample_function(self):
|
||||
"""Create a sample function that accepts credentials for testing.
|
||||
|
||||
This simulates a real Google API tool function that needs
|
||||
authenticated credentials to perform its work.
|
||||
"""
|
||||
|
||||
def sample_func(param1: str, credentials: Credentials = None) -> dict:
|
||||
"""Sample function that uses Google API credentials."""
|
||||
if credentials:
|
||||
return {"result": f"Success with {param1}", "authenticated": True}
|
||||
else:
|
||||
return {"result": f"Success with {param1}", "authenticated": False}
|
||||
|
||||
return sample_func
|
||||
|
||||
@pytest.fixture
|
||||
def async_sample_function(self):
|
||||
"""Create an async sample function for testing async execution paths."""
|
||||
|
||||
async def async_sample_func(
|
||||
param1: str, credentials: Credentials = None
|
||||
) -> dict:
|
||||
"""Async sample function that uses Google API credentials."""
|
||||
if credentials:
|
||||
return {"result": f"Async success with {param1}", "authenticated": True}
|
||||
else:
|
||||
return {
|
||||
"result": f"Async success with {param1}",
|
||||
"authenticated": False,
|
||||
}
|
||||
|
||||
return async_sample_func
|
||||
|
||||
@pytest.fixture
|
||||
def credentials_config(self):
|
||||
"""Create credentials configuration for testing."""
|
||||
return BigQueryCredentialsConfig(
|
||||
client_id="test_client_id",
|
||||
client_secret="test_client_secret",
|
||||
scopes=["https://www.googleapis.com/auth/bigquery"],
|
||||
)
|
||||
|
||||
def test_tool_initialization_with_credentials(
|
||||
self, sample_function, credentials_config
|
||||
):
|
||||
"""Test that BigQueryTool initializes correctly with credentials.
|
||||
|
||||
The tool should properly inherit from FunctionTool while adding
|
||||
Google API specific credential management capabilities.
|
||||
"""
|
||||
tool = BigQueryTool(func=sample_function, credentials=credentials_config)
|
||||
|
||||
assert tool.func == sample_function
|
||||
assert tool.credentials_manager is not None
|
||||
assert isinstance(tool.credentials_manager, BigQueryCredentialsManager)
|
||||
# Verify that 'credentials' parameter is ignored in function signature analysis
|
||||
assert "credentials" in tool._ignore_params
|
||||
|
||||
def test_tool_initialization_without_credentials(self, sample_function):
|
||||
"""Test tool initialization when no credential management is needed.
|
||||
|
||||
Some tools might handle authentication externally or use service
|
||||
accounts, so credential management should be optional.
|
||||
"""
|
||||
tool = BigQueryTool(func=sample_function, credentials=None)
|
||||
|
||||
assert tool.func == sample_function
|
||||
assert tool.credentials_manager is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_async_with_valid_credentials(
|
||||
self, sample_function, credentials_config, mock_tool_context
|
||||
):
|
||||
"""Test successful tool execution with valid credentials.
|
||||
|
||||
This tests the main happy path where credentials are available
|
||||
and the underlying function executes successfully.
|
||||
"""
|
||||
tool = BigQueryTool(func=sample_function, credentials=credentials_config)
|
||||
|
||||
# Mock the credentials manager to return valid credentials
|
||||
mock_creds = Mock(spec=Credentials)
|
||||
with patch.object(
|
||||
tool.credentials_manager,
|
||||
"get_valid_credentials",
|
||||
return_value=mock_creds,
|
||||
) as mock_get_creds:
|
||||
|
||||
result = await tool.run_async(
|
||||
args={"param1": "test_value"}, tool_context=mock_tool_context
|
||||
)
|
||||
|
||||
mock_get_creds.assert_called_once_with(mock_tool_context)
|
||||
assert result["result"] == "Success with test_value"
|
||||
assert result["authenticated"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_async_oauth_flow_in_progress(
|
||||
self, sample_function, credentials_config, mock_tool_context
|
||||
):
|
||||
"""Test tool behavior when OAuth flow is in progress.
|
||||
|
||||
When credentials aren't available and OAuth flow is needed,
|
||||
the tool should return a user-friendly message rather than failing.
|
||||
"""
|
||||
tool = BigQueryTool(func=sample_function, credentials=credentials_config)
|
||||
|
||||
# Mock credentials manager to return None (OAuth flow in progress)
|
||||
with patch.object(
|
||||
tool.credentials_manager, "get_valid_credentials", return_value=None
|
||||
) as mock_get_creds:
|
||||
|
||||
result = await tool.run_async(
|
||||
args={"param1": "test_value"}, tool_context=mock_tool_context
|
||||
)
|
||||
|
||||
mock_get_creds.assert_called_once_with(mock_tool_context)
|
||||
assert "authorization is required" in result.lower()
|
||||
assert tool.name in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_async_without_credentials_manager(
|
||||
self, sample_function, mock_tool_context
|
||||
):
|
||||
"""Test tool execution when no credential management is configured.
|
||||
|
||||
Tools without credential managers should execute normally,
|
||||
passing None for credentials if the function accepts them.
|
||||
"""
|
||||
tool = BigQueryTool(func=sample_function, credentials=None)
|
||||
|
||||
result = await tool.run_async(
|
||||
args={"param1": "test_value"}, tool_context=mock_tool_context
|
||||
)
|
||||
|
||||
assert result["result"] == "Success with test_value"
|
||||
assert result["authenticated"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_async_with_async_function(
|
||||
self, async_sample_function, credentials_config, mock_tool_context
|
||||
):
|
||||
"""Test that async functions are properly handled.
|
||||
|
||||
The tool should correctly detect and execute async functions,
|
||||
which is important for tools that make async API calls.
|
||||
"""
|
||||
tool = BigQueryTool(
|
||||
func=async_sample_function, credentials=credentials_config
|
||||
)
|
||||
|
||||
mock_creds = Mock(spec=Credentials)
|
||||
with patch.object(
|
||||
tool.credentials_manager,
|
||||
"get_valid_credentials",
|
||||
return_value=mock_creds,
|
||||
):
|
||||
|
||||
result = await tool.run_async(
|
||||
args={"param1": "test_value"}, tool_context=mock_tool_context
|
||||
)
|
||||
|
||||
assert result["result"] == "Async success with test_value"
|
||||
assert result["authenticated"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_async_exception_handling(
|
||||
self, credentials_config, mock_tool_context
|
||||
):
|
||||
"""Test that exceptions in tool execution are properly handled.
|
||||
|
||||
Tools should gracefully handle errors and return structured
|
||||
error responses rather than letting exceptions propagate.
|
||||
"""
|
||||
|
||||
def failing_function(param1: str, credentials: Credentials = None) -> dict:
|
||||
raise ValueError("Something went wrong")
|
||||
|
||||
tool = BigQueryTool(func=failing_function, credentials=credentials_config)
|
||||
|
||||
mock_creds = Mock(spec=Credentials)
|
||||
with patch.object(
|
||||
tool.credentials_manager,
|
||||
"get_valid_credentials",
|
||||
return_value=mock_creds,
|
||||
):
|
||||
|
||||
result = await tool.run_async(
|
||||
args={"param1": "test_value"}, tool_context=mock_tool_context
|
||||
)
|
||||
|
||||
assert result["status"] == "ERROR"
|
||||
assert "Something went wrong" in result["error_details"]
|
||||
|
||||
def test_function_signature_analysis(self, credentials_config):
|
||||
"""Test that function signature analysis correctly handles credentials parameter.
|
||||
|
||||
The tool should properly identify and handle the credentials parameter
|
||||
while preserving other parameter analysis for LLM function calling.
|
||||
"""
|
||||
|
||||
def complex_function(
|
||||
required_param: str,
|
||||
optional_param: str = "default",
|
||||
credentials: Credentials = None,
|
||||
) -> dict:
|
||||
return {"success": True}
|
||||
|
||||
tool = BigQueryTool(func=complex_function, credentials=credentials_config)
|
||||
|
||||
# The 'credentials' parameter should be ignored in mandatory args analysis
|
||||
mandatory_args = tool._get_mandatory_args()
|
||||
assert "required_param" in mandatory_args
|
||||
assert "credentials" not in mandatory_args
|
||||
assert "optional_param" not in mandatory_args
|
||||
@@ -0,0 +1,123 @@
|
||||
# Copyright 2025 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 google.adk.tools.bigquery import BigQueryCredentialsConfig
|
||||
from google.adk.tools.bigquery import BigQueryTool
|
||||
from google.adk.tools.bigquery import BigQueryToolset
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bigquery_toolset_tools_default():
|
||||
"""Test default BigQuery toolset.
|
||||
|
||||
This test verifies the behavior of the BigQuery toolset when no filter is
|
||||
specified.
|
||||
"""
|
||||
credentials_config = BigQueryCredentialsConfig(
|
||||
client_id="abc", client_secret="def"
|
||||
)
|
||||
toolset = BigQueryToolset(credentials_config=credentials_config)
|
||||
tools = await toolset.get_tools()
|
||||
assert tools is not None
|
||||
|
||||
assert len(tools) == 5
|
||||
assert all([isinstance(tool, BigQueryTool) for tool in tools])
|
||||
|
||||
expected_tool_names = set([
|
||||
"list_dataset_ids",
|
||||
"get_dataset_info",
|
||||
"list_table_ids",
|
||||
"get_table_info",
|
||||
"execute_sql",
|
||||
])
|
||||
actual_tool_names = set([tool.name for tool in tools])
|
||||
assert actual_tool_names == expected_tool_names
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"selected_tools",
|
||||
[
|
||||
pytest.param([], id="None"),
|
||||
pytest.param(
|
||||
["list_dataset_ids", "get_dataset_info"], id="dataset-metadata"
|
||||
),
|
||||
pytest.param(["list_table_ids", "get_table_info"], id="table-metadata"),
|
||||
pytest.param(["execute_sql"], id="query"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_bigquery_toolset_tools_selective(selected_tools):
|
||||
"""Test BigQuery toolset with filter.
|
||||
|
||||
This test verifies the behavior of the BigQuery toolset when filter is
|
||||
specified. A use case for this would be when the agent builder wants to
|
||||
use only a subset of the tools provided by the toolset.
|
||||
"""
|
||||
credentials_config = BigQueryCredentialsConfig(
|
||||
client_id="abc", client_secret="def"
|
||||
)
|
||||
toolset = BigQueryToolset(
|
||||
credentials_config=credentials_config, tool_filter=selected_tools
|
||||
)
|
||||
tools = await toolset.get_tools()
|
||||
assert tools is not None
|
||||
|
||||
assert len(tools) == len(selected_tools)
|
||||
assert all([isinstance(tool, BigQueryTool) for tool in tools])
|
||||
|
||||
expected_tool_names = set(selected_tools)
|
||||
actual_tool_names = set([tool.name for tool in tools])
|
||||
assert actual_tool_names == expected_tool_names
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("selected_tools", "returned_tools"),
|
||||
[
|
||||
pytest.param(["unknown"], [], id="all-unknown"),
|
||||
pytest.param(
|
||||
["unknown", "execute_sql"],
|
||||
["execute_sql"],
|
||||
id="mixed-known-unknown",
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_bigquery_toolset_unknown_tool_raises(
|
||||
selected_tools, returned_tools
|
||||
):
|
||||
"""Test BigQuery toolset with filter.
|
||||
|
||||
This test verifies the behavior of the BigQuery toolset when filter is
|
||||
specified with an unknown tool.
|
||||
"""
|
||||
credentials_config = BigQueryCredentialsConfig(
|
||||
client_id="abc", client_secret="def"
|
||||
)
|
||||
|
||||
toolset = BigQueryToolset(
|
||||
credentials_config=credentials_config, tool_filter=selected_tools
|
||||
)
|
||||
|
||||
tools = await toolset.get_tools()
|
||||
assert tools is not None
|
||||
|
||||
assert len(tools) == len(returned_tools)
|
||||
assert all([isinstance(tool, BigQueryTool) for tool in tools])
|
||||
|
||||
expected_tool_names = set(returned_tools)
|
||||
actual_tool_names = set([tool.name for tool in tools])
|
||||
assert actual_tool_names == expected_tool_names
|
||||
@@ -16,7 +16,8 @@ from typing import Any
|
||||
from typing import Dict
|
||||
from typing import List
|
||||
|
||||
from fastapi.openapi.models import Response, Schema
|
||||
from fastapi.openapi.models import Response
|
||||
from fastapi.openapi.models import Schema
|
||||
from google.adk.tools.openapi_tool.common.common import ApiParameter
|
||||
from google.adk.tools.openapi_tool.common.common import PydocHelper
|
||||
from google.adk.tools.openapi_tool.common.common import rename_python_keywords
|
||||
|
||||
@@ -371,9 +371,7 @@ def test_parse_external_ref_raises_error(openapi_spec_generator):
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": (
|
||||
"external_file.json#/components/schemas/ExternalSchema"
|
||||
)
|
||||
"$ref": "external_file.json#/components/schemas/ExternalSchema"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -14,9 +14,11 @@
|
||||
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi.openapi.models import MediaType, Operation
|
||||
from fastapi.openapi.models import MediaType
|
||||
from fastapi.openapi.models import Operation
|
||||
from fastapi.openapi.models import Parameter as OpenAPIParameter
|
||||
from fastapi.openapi.models import RequestBody
|
||||
from fastapi.openapi.models import Schema as OpenAPISchema
|
||||
@@ -25,13 +27,13 @@ from google.adk.tools.openapi_tool.auth.auth_helpers import token_to_scheme_cred
|
||||
from google.adk.tools.openapi_tool.common.common import ApiParameter
|
||||
from google.adk.tools.openapi_tool.openapi_spec_parser.openapi_spec_parser import OperationEndpoint
|
||||
from google.adk.tools.openapi_tool.openapi_spec_parser.operation_parser import OperationParser
|
||||
from google.adk.tools.openapi_tool.openapi_spec_parser.rest_api_tool import (
|
||||
RestApiTool,
|
||||
snake_to_lower_camel,
|
||||
to_gemini_schema,
|
||||
)
|
||||
from google.adk.tools.openapi_tool.openapi_spec_parser.rest_api_tool import RestApiTool
|
||||
from google.adk.tools.openapi_tool.openapi_spec_parser.rest_api_tool import snake_to_lower_camel
|
||||
from google.adk.tools.openapi_tool.openapi_spec_parser.rest_api_tool import to_gemini_schema
|
||||
from google.adk.tools.tool_context import ToolContext
|
||||
from google.genai.types import FunctionDeclaration, Schema, Type
|
||||
from google.genai.types import FunctionDeclaration
|
||||
from google.genai.types import Schema
|
||||
from google.genai.types import Type
|
||||
import pytest
|
||||
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ from google.adk.tools.function_tool import FunctionTool
|
||||
from google.adk.tools.retrieval.vertex_ai_rag_retrieval import VertexAiRagRetrieval
|
||||
from google.genai import types
|
||||
|
||||
from ... import utils
|
||||
from ... import testing_utils
|
||||
|
||||
|
||||
def noop_tool(x: str) -> str:
|
||||
@@ -28,7 +28,7 @@ def test_vertex_rag_retrieval_for_gemini_1_x():
|
||||
responses = [
|
||||
'response1',
|
||||
]
|
||||
mockModel = utils.MockModel.create(responses=responses)
|
||||
mockModel = testing_utils.MockModel.create(responses=responses)
|
||||
mockModel.model = 'gemini-1.5-pro'
|
||||
|
||||
# Calls the first time.
|
||||
@@ -45,12 +45,12 @@ def test_vertex_rag_retrieval_for_gemini_1_x():
|
||||
)
|
||||
],
|
||||
)
|
||||
runner = utils.InMemoryRunner(agent)
|
||||
runner = testing_utils.InMemoryRunner(agent)
|
||||
events = runner.run('test1')
|
||||
|
||||
# Asserts the requests.
|
||||
assert len(mockModel.requests) == 1
|
||||
assert utils.simplify_contents(mockModel.requests[0].contents) == [
|
||||
assert testing_utils.simplify_contents(mockModel.requests[0].contents) == [
|
||||
('user', 'test1'),
|
||||
]
|
||||
assert len(mockModel.requests[0].config.tools) == 1
|
||||
@@ -65,7 +65,7 @@ def test_vertex_rag_retrieval_for_gemini_1_x_with_another_function_tool():
|
||||
responses = [
|
||||
'response1',
|
||||
]
|
||||
mockModel = utils.MockModel.create(responses=responses)
|
||||
mockModel = testing_utils.MockModel.create(responses=responses)
|
||||
mockModel.model = 'gemini-1.5-pro'
|
||||
|
||||
# Calls the first time.
|
||||
@@ -83,12 +83,12 @@ def test_vertex_rag_retrieval_for_gemini_1_x_with_another_function_tool():
|
||||
FunctionTool(func=noop_tool),
|
||||
],
|
||||
)
|
||||
runner = utils.InMemoryRunner(agent)
|
||||
runner = testing_utils.InMemoryRunner(agent)
|
||||
events = runner.run('test1')
|
||||
|
||||
# Asserts the requests.
|
||||
assert len(mockModel.requests) == 1
|
||||
assert utils.simplify_contents(mockModel.requests[0].contents) == [
|
||||
assert testing_utils.simplify_contents(mockModel.requests[0].contents) == [
|
||||
('user', 'test1'),
|
||||
]
|
||||
assert len(mockModel.requests[0].config.tools[0].function_declarations) == 2
|
||||
@@ -107,7 +107,7 @@ def test_vertex_rag_retrieval_for_gemini_2_x():
|
||||
responses = [
|
||||
'response1',
|
||||
]
|
||||
mockModel = utils.MockModel.create(responses=responses)
|
||||
mockModel = testing_utils.MockModel.create(responses=responses)
|
||||
mockModel.model = 'gemini-2.0-flash'
|
||||
|
||||
# Calls the first time.
|
||||
@@ -124,12 +124,12 @@ def test_vertex_rag_retrieval_for_gemini_2_x():
|
||||
)
|
||||
],
|
||||
)
|
||||
runner = utils.InMemoryRunner(agent)
|
||||
runner = testing_utils.InMemoryRunner(agent)
|
||||
events = runner.run('test1')
|
||||
|
||||
# Asserts the requests.
|
||||
assert len(mockModel.requests) == 1
|
||||
assert utils.simplify_contents(mockModel.requests[0].contents) == [
|
||||
assert testing_utils.simplify_contents(mockModel.requests[0].contents) == [
|
||||
('user', 'test1'),
|
||||
]
|
||||
assert len(mockModel.requests[0].config.tools) == 1
|
||||
|
||||
@@ -20,7 +20,7 @@ from pydantic import BaseModel
|
||||
import pytest
|
||||
from pytest import mark
|
||||
|
||||
from .. import utils
|
||||
from .. import testing_utils
|
||||
|
||||
pytestmark = pytest.mark.skip(
|
||||
reason='Skipping until tool.func evaluations are fixed (async)'
|
||||
@@ -50,7 +50,7 @@ def change_state_callback(callback_context: CallbackContext):
|
||||
|
||||
|
||||
def test_no_schema():
|
||||
mock_model = utils.MockModel.create(
|
||||
mock_model = testing_utils.MockModel.create(
|
||||
responses=[
|
||||
function_call_no_schema,
|
||||
'response1',
|
||||
@@ -69,9 +69,9 @@ def test_no_schema():
|
||||
tools=[AgentTool(agent=tool_agent)],
|
||||
)
|
||||
|
||||
runner = utils.InMemoryRunner(root_agent)
|
||||
runner = testing_utils.InMemoryRunner(root_agent)
|
||||
|
||||
assert utils.simplify_events(runner.run('test1')) == [
|
||||
assert testing_utils.simplify_events(runner.run('test1')) == [
|
||||
('root_agent', function_call_no_schema),
|
||||
('root_agent', function_response_no_schema),
|
||||
('root_agent', 'response2'),
|
||||
@@ -81,7 +81,7 @@ def test_no_schema():
|
||||
def test_update_state():
|
||||
"""The agent tool can read and change parent state."""
|
||||
|
||||
mock_model = utils.MockModel.create(
|
||||
mock_model = testing_utils.MockModel.create(
|
||||
responses=[
|
||||
function_call_no_schema,
|
||||
'{"custom_output": "response1"}',
|
||||
@@ -102,7 +102,7 @@ def test_update_state():
|
||||
tools=[AgentTool(agent=tool_agent)],
|
||||
)
|
||||
|
||||
runner = utils.InMemoryRunner(root_agent)
|
||||
runner = testing_utils.InMemoryRunner(root_agent)
|
||||
runner.session.state['state_1'] = 'state1_value'
|
||||
|
||||
runner.run('test1')
|
||||
@@ -128,7 +128,7 @@ def test_custom_schema():
|
||||
class CustomOutput(BaseModel):
|
||||
custom_output: str
|
||||
|
||||
mock_model = utils.MockModel.create(
|
||||
mock_model = testing_utils.MockModel.create(
|
||||
responses=[
|
||||
function_call_custom,
|
||||
'{"custom_output": "response1"}',
|
||||
@@ -150,10 +150,10 @@ def test_custom_schema():
|
||||
tools=[AgentTool(agent=tool_agent)],
|
||||
)
|
||||
|
||||
runner = utils.InMemoryRunner(root_agent)
|
||||
runner = testing_utils.InMemoryRunner(root_agent)
|
||||
runner.session.state['state_1'] = 'state1_value'
|
||||
|
||||
assert utils.simplify_events(runner.run('test1')) == [
|
||||
assert testing_utils.simplify_events(runner.run('test1')) == [
|
||||
('root_agent', function_call_custom),
|
||||
('root_agent', function_response_custom),
|
||||
('root_agent', 'response2'),
|
||||
|
||||
@@ -39,6 +39,18 @@ async def async_function_for_testing_with_2_arg_and_no_tool_context(arg1, arg2):
|
||||
return arg1
|
||||
|
||||
|
||||
class AsyncCallableWith2ArgsAndNoToolContext:
|
||||
|
||||
def __init__(self):
|
||||
self.__name__ = "Async callable name"
|
||||
self.__doc__ = "Async callable doc"
|
||||
|
||||
async def __call__(self, arg1, arg2):
|
||||
assert arg1
|
||||
assert arg2
|
||||
return arg1
|
||||
|
||||
|
||||
def function_for_testing_with_1_arg_and_tool_context(arg1, tool_context):
|
||||
"""Function for testing with 1 arge and tool context."""
|
||||
assert arg1
|
||||
@@ -46,6 +58,15 @@ def function_for_testing_with_1_arg_and_tool_context(arg1, tool_context):
|
||||
return arg1
|
||||
|
||||
|
||||
class AsyncCallableWith1ArgAndToolContext:
|
||||
|
||||
async def __call__(self, arg1, tool_context):
|
||||
"""Async call doc"""
|
||||
assert arg1
|
||||
assert tool_context
|
||||
return arg1
|
||||
|
||||
|
||||
def function_for_testing_with_2_arg_and_no_tool_context(arg1, arg2):
|
||||
"""Function for testing with 2 arge and no tool context."""
|
||||
assert arg1
|
||||
@@ -65,6 +86,16 @@ def function_for_testing_with_4_arg_and_no_tool_context(arg1, arg2, arg3, arg4):
|
||||
pass
|
||||
|
||||
|
||||
def function_returning_none() -> None:
|
||||
"""Function for testing with no return value."""
|
||||
return None
|
||||
|
||||
|
||||
def function_returning_empty_dict() -> dict[str, str]:
|
||||
"""Function for testing with empty dict return value."""
|
||||
return {}
|
||||
|
||||
|
||||
def test_init():
|
||||
"""Test that the FunctionTool is initialized correctly."""
|
||||
tool = FunctionTool(function_for_testing_with_no_args)
|
||||
@@ -73,6 +104,22 @@ def test_init():
|
||||
assert tool.func == function_for_testing_with_no_args
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_function_returning_none():
|
||||
"""Test that the function returns with None actually returning None."""
|
||||
tool = FunctionTool(function_returning_none)
|
||||
result = await tool.run_async(args={}, tool_context=MagicMock())
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_function_returning_empty_dict():
|
||||
"""Test that the function returns with empty dict actually returning empty dict."""
|
||||
tool = FunctionTool(function_returning_empty_dict)
|
||||
result = await tool.run_async(args={}, tool_context=MagicMock())
|
||||
assert isinstance(result, dict)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_async_with_tool_context_async_func():
|
||||
"""Test that run_async calls the function with tool_context when tool_context is in signature (async function)."""
|
||||
@@ -83,6 +130,18 @@ async def test_run_async_with_tool_context_async_func():
|
||||
assert result == "test_value_1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_async_with_tool_context_async_callable():
|
||||
"""Test that run_async calls the callable with tool_context when tool_context is in signature (async callable)."""
|
||||
|
||||
tool = FunctionTool(AsyncCallableWith1ArgAndToolContext())
|
||||
args = {"arg1": "test_value_1"}
|
||||
result = await tool.run_async(args=args, tool_context=MagicMock())
|
||||
assert result == "test_value_1"
|
||||
assert tool.name == "AsyncCallableWith1ArgAndToolContext"
|
||||
assert tool.description == "Async call doc"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_async_without_tool_context_async_func():
|
||||
"""Test that run_async calls the function without tool_context when tool_context is not in signature (async function)."""
|
||||
@@ -92,6 +151,17 @@ async def test_run_async_without_tool_context_async_func():
|
||||
assert result == "test_value_1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_async_without_tool_context_async_callable():
|
||||
"""Test that run_async calls the callable without tool_context when tool_context is not in signature (async callable)."""
|
||||
tool = FunctionTool(AsyncCallableWith2ArgsAndNoToolContext())
|
||||
args = {"arg1": "test_value_1", "arg2": "test_value_2"}
|
||||
result = await tool.run_async(args=args, tool_context=MagicMock())
|
||||
assert result == "test_value_1"
|
||||
assert tool.name == "Async callable name"
|
||||
assert tool.description == "Async callable doc"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_async_with_tool_context_sync_func():
|
||||
"""Test that run_async calls the function with tool_context when tool_context is in signature (synchronous function)."""
|
||||
@@ -117,11 +187,9 @@ async def test_run_async_1_missing_arg_sync_func():
|
||||
args = {"arg1": "test_value_1"}
|
||||
result = await tool.run_async(args=args, tool_context=MagicMock())
|
||||
assert result == {
|
||||
"error": (
|
||||
"""Invoking `function_for_testing_with_2_arg_and_no_tool_context()` failed as the following mandatory input parameters are not present:
|
||||
"error": """Invoking `function_for_testing_with_2_arg_and_no_tool_context()` failed as the following mandatory input parameters are not present:
|
||||
arg2
|
||||
You could retry calling this tool, but it is IMPORTANT for you to provide all the mandatory parameters."""
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -132,11 +200,9 @@ async def test_run_async_1_missing_arg_async_func():
|
||||
args = {"arg2": "test_value_1"}
|
||||
result = await tool.run_async(args=args, tool_context=MagicMock())
|
||||
assert result == {
|
||||
"error": (
|
||||
"""Invoking `async_function_for_testing_with_2_arg_and_no_tool_context()` failed as the following mandatory input parameters are not present:
|
||||
"error": """Invoking `async_function_for_testing_with_2_arg_and_no_tool_context()` failed as the following mandatory input parameters are not present:
|
||||
arg1
|
||||
You could retry calling this tool, but it is IMPORTANT for you to provide all the mandatory parameters."""
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -147,13 +213,11 @@ async def test_run_async_3_missing_arg_sync_func():
|
||||
args = {"arg2": "test_value_1"}
|
||||
result = await tool.run_async(args=args, tool_context=MagicMock())
|
||||
assert result == {
|
||||
"error": (
|
||||
"""Invoking `function_for_testing_with_4_arg_and_no_tool_context()` failed as the following mandatory input parameters are not present:
|
||||
"error": """Invoking `function_for_testing_with_4_arg_and_no_tool_context()` failed as the following mandatory input parameters are not present:
|
||||
arg1
|
||||
arg3
|
||||
arg4
|
||||
You could retry calling this tool, but it is IMPORTANT for you to provide all the mandatory parameters."""
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -164,13 +228,11 @@ async def test_run_async_3_missing_arg_async_func():
|
||||
args = {"arg3": "test_value_1"}
|
||||
result = await tool.run_async(args=args, tool_context=MagicMock())
|
||||
assert result == {
|
||||
"error": (
|
||||
"""Invoking `async_function_for_testing_with_4_arg_and_no_tool_context()` failed as the following mandatory input parameters are not present:
|
||||
"error": """Invoking `async_function_for_testing_with_4_arg_and_no_tool_context()` failed as the following mandatory input parameters are not present:
|
||||
arg1
|
||||
arg2
|
||||
arg4
|
||||
You could retry calling this tool, but it is IMPORTANT for you to provide all the mandatory parameters."""
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -181,14 +243,12 @@ async def test_run_async_missing_all_arg_sync_func():
|
||||
args = {}
|
||||
result = await tool.run_async(args=args, tool_context=MagicMock())
|
||||
assert result == {
|
||||
"error": (
|
||||
"""Invoking `function_for_testing_with_4_arg_and_no_tool_context()` failed as the following mandatory input parameters are not present:
|
||||
"error": """Invoking `function_for_testing_with_4_arg_and_no_tool_context()` failed as the following mandatory input parameters are not present:
|
||||
arg1
|
||||
arg2
|
||||
arg3
|
||||
arg4
|
||||
You could retry calling this tool, but it is IMPORTANT for you to provide all the mandatory parameters."""
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -199,14 +259,12 @@ async def test_run_async_missing_all_arg_async_func():
|
||||
args = {}
|
||||
result = await tool.run_async(args=args, tool_context=MagicMock())
|
||||
assert result == {
|
||||
"error": (
|
||||
"""Invoking `async_function_for_testing_with_4_arg_and_no_tool_context()` failed as the following mandatory input parameters are not present:
|
||||
"error": """Invoking `async_function_for_testing_with_4_arg_and_no_tool_context()` failed as the following mandatory input parameters are not present:
|
||||
arg1
|
||||
arg2
|
||||
arg3
|
||||
arg4
|
||||
You could retry calling this tool, but it is IMPORTANT for you to provide all the mandatory parameters."""
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user